Compare commits

..

25 Commits

Author SHA1 Message Date
Hocuri
b08ba4bb8c api!: Change list_transports to additionally return whether a transport is unpublished 2026-03-17 16:12:22 +01:00
Hocuri
b94d9f95b6 api: Add list_transports_ex() and set_transport_unpublished() functions 2026-03-17 15:28:09 +01:00
Hocuri
39dc495944 refactor: Move transport_tests to their own file 2026-03-17 15:27:15 +01:00
WofWca
bf02785a36 feat: add IncomingCallAccepted.from_this_device 2026-03-14 22:21:46 +04:00
iequidoo
01b2aa0f66 fix: Mark call message as seen when accepting/declining a call (#7842) 2026-03-14 13:46:25 -03:00
iequidoo
fb46c34b55 test: Shift time even more in flaky test_sync_broadcast_and_send_message
As of now, alice1 makes 3 more calls of create_smeared_timestamp() than alice2 does, so we need to
shift time by 3s to fix the test.
2026-03-14 16:20:46 +01:00
link2xt
9393753190 chore: bump version to 2.46.0-dev 2026-03-14 02:58:19 +00:00
link2xt
d9056fd187 chore(release): prepare for 2.45.0 2026-03-14 02:23:25 +00:00
link2xt
7b17b1f8b8 test: set some address for test context in decrypt_bytes()
This is needed to create pseudo transport, otherwise
public key generation fails.
2026-03-14 02:23:25 +00:00
Hocuri
d8d7f12af0 fix: Domain separation between securejoin auth tokens and broadcast channel secrets (#7981)
Can be reviewed commit-by-commit.

This fixes another silly thing you can do with securejoinv3: show Bob a
QR code with auth token that is a broadcast channel secret of a known
channel, then never respond. Bob will decrypt messages from the channel
and drop them because they are sent by the "wrong" sender.

This can be avoided with domain separation, instead of
encrypting/decrypting securejoinv3 messages directly with auth token,
encrypt/decrypt them with `securejoin/<auth token>` as the secret or
even `securejoinv3/<alice's fingerprint>/<auth token>`. For existing
broadcast channels we cannot do this, but for securejoinv3 that is not
released yet this looks like an improvement that avoids at least this
problem.

Credits to link2xt for noticing the problem.

This also adds Alice's fingerprint to the auth tokens, which
was pretty easy to do. I find it hard to develop an intuition for
whether this is important, or whether we will be annoyed by it in the
future.

**Note:** This means that QR code scans will not work if one of the chat
partners uses a self-compiled core between c724e2981 and merging this PR
here. This is fine; we will just have to tell the other developers to
update their self-compiled cores.
2026-03-13 22:01:19 +01:00
link2xt
0150d38ddd fix: update add_timestamp when the transport is selected as primary 2026-03-13 15:02:08 +00:00
link2xt
11b6a108f5 feat: merge OpenPGP certificates and distribute relays in them
We put all relay addresses as a notation subpacket
in the direct key signature to distribute the relay addresses.
2026-03-13 15:02:08 +00:00
link2xt
54858361a9 feat: mutex to prevent fetching from multiple IMAP servers at the same time 2026-03-13 15:02:08 +00:00
link2xt
6a705a3ef6 fix: update device chats at the end of configuration 2026-03-13 15:02:08 +00:00
link2xt
a23e41ea6d fix: fix debug assert message incorrectly talking about past members in the current member branch 2026-03-13 15:02:08 +00:00
link2xt
bdca3e5c09 refactor: order self addresses by addition timestamp
This way the order does not change when
primary address is changed.
2026-03-13 15:02:08 +00:00
link2xt
a61a25f139 fix(deltachat_rpc_client): make @futuremethod decorator keep method metadata
Without this change methods decorated with `@futuremethod`
do not appear in the documentation.
2026-03-13 03:17:44 +00:00
Hocuri
5404e683eb 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.
2026-03-12 18:59:19 +00:00
B. Petersen
80acc9d467 docs: use correct define for 'description changed' info message 2026-03-11 23:41:38 +01:00
B. Petersen
3c5af7a559 fix: use correct string for encryption info
encryption info needs a dedicated string for "Messages are end-to-end encrypted"
as the UI will add more infomation to the info messages,
smth. as "Tap for more information".

an alternative fix would have been to let the UI render the info-message
differently, but adding another string to core causes less friction.
2026-03-11 15:03:07 +01:00
dependabot[bot]
f7e9973fb4 chore(cargo): bump quinn-proto from 0.11.9 to 0.11.14
Bumps [quinn-proto](https://github.com/quinn-rs/quinn) from 0.11.9 to 0.11.14.
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.9...quinn-proto-0.11.14)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 11:55:04 +00:00
Jagoda Estera Ślązak
c0a3d77301 fix: Correct channel system messages (#7959)
Previously channels used the same system messages
as groups, which can be confusing.

Fixes #7951

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-03-11 07:56:30 +00:00
iequidoo
9891c2a531 fix: Add "member added" messages to OutBroadcast when executing SetPgpContacts sync message (#7952)
If one of broadcast owner's devices didn't add a new subscriber for any reason, e.g. because of
missing SecureJoin messages, this device shall add "member added" messages when syncing the member
list from the `SetPgpContacts` message.
2026-03-10 22:03:04 -03:00
iequidoo
f85c625799 test: Work around test_sync_broadcast_and_send_message flakiness
The test sometimes fails because of wrong message ordering for bob:
     [...]
     Waiting for the device of alice@example.org to reply… [NOTICED][INFO]
    <Msg#2010🔒:  (Contact#Contact#2001): hi [FRESH]
     Msg#2008🔒:  (Contact#Contact#2001): You joined the channel. [FRESH][INFO]
    >Msg#2010🔒:  (Contact#Contact#2001): hi [FRESH]
     Msg#2011🔒:  (Contact#Contact#2001): Member Me removed by alice@example.org. [FRESH][INFO]

This adds `SystemTime::shift(Duration::from_secs(1))` as a workaround.

Co-authored-by: Hocuri <hocuri@gmx.de>
2026-03-10 10:00:54 -03:00
link2xt
b30f93a57d ci: update Rust to 1.94.0 2026-03-10 09:21:24 +00:00
59 changed files with 2338 additions and 868 deletions

View File

@@ -20,7 +20,7 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.93.0
RUST_VERSION: 1.94.0
# Minimum Supported Rust Version
MSRV: 1.88.0

View File

@@ -1,5 +1,90 @@
# Changelog
## [2.45.0] - 2026-03-14
### API-Changes
- JSON-RPC: add `createQrSvg` ([#7949](https://github.com/chatmail/core/pull/7949)).
### Features / Changes
- Do not read own public key from the database.
- Securejoin v3, encrypt all securejoin messages ([#7754](https://github.com/chatmail/core/pull/7754)).
- Domain separation between securejoin auth tokens and broadcast channel secrets ([#7981](https://github.com/chatmail/core/pull/7981)).
- Merge OpenPGP certificates and distribute relays in them.
- Advertise SEIPDv2 feature for new keys.
- Don't depend on cleartext `Chat-Version`, `In-Reply-To`, and `References` headers for `prefetch_should_download` ([#7932](https://github.com/chatmail/core/pull/7932)).
- Don't send unencrypted `In-Reply-To` and `References` headers ([#7935](https://github.com/chatmail/core/pull/7935)).
- Don't send unencrypted `Auto-Submitted` header ([#7938](https://github.com/chatmail/core/pull/7938)).
- Remove QR code tokens sync compatibility code.
- Mutex to prevent fetching from multiple IMAP servers at the same time.
- Add support to gif stickers ([#7941](https://github.com/chatmail/core/pull/7941))
### Fixes
- Fix the deadlock by adding a mutex around `wal_checkpoint()`.
- Do not run more than one housekeeping at a time.
- ffi: don't steal Arc in `dc_jsonrpc_init` ([#7962](https://github.com/chatmail/core/pull/7962)).
- Handle the case that the user starts a securejoin, and then deletes the contact ([#7883](https://github.com/chatmail/core/pull/7883)).
- Do not trash pre-message if it is received twice.
- Set `is_chatmail` during initial configuration.
- vCard: Improve property value escaping ([#7931](https://github.com/chatmail/core/pull/7931)).
- Percent-decode the address in `dclogin://` URLs.
- Make broadcast owner and subscriber hidden contacts for each other ([#7856](https://github.com/chatmail/core/pull/7856)).
- Set proper placeholder texts for system messages ([#7953](https://github.com/chatmail/core/pull/7953)).
- Add "member added" messages to `OutBroadcast` when executing `SetPgpContacts` sync message ([#7952](https://github.com/chatmail/core/pull/7952)).
- Correct channel system messages ([#7959](https://github.com/chatmail/core/pull/7959)).
- Drop messages encrypted with the wrong symmetric secret ([#7963](https://github.com/chatmail/core/pull/7963)).
- Fix debug assert message incorrectly talking about past members in the current member branch.
- Update device chats at the end of configuration.
- `deltachat_rpc_client`: make `@futuremethod` decorator keep method metadata.
- Use the correct chat description stock string again ([#7939](https://github.com/chatmail/core/pull/7939)).
- Use correct string for encryption info.
### CI
- Update Rust to 1.94.0.
- Allow non-hash references for `actions/*` and `dependabot/*`.
- update zizmor workflow to use zizmorcore/zizmor-action.
### Documentation
- update `store_self_keypair()` documentation.
- Fix documentation for membership change stock strings ([#7944](https://github.com/chatmail/core/pull/7944)).
- use correct define for 'description changed' info message.
### Refactor
- Un-resultify `KeyPair::new()`.
- Remove `KeyPair` type.
- pgp: do not use legacy key ID except for IssuerKeyId subpacket.
- `use super::*` in qr::dclogin_scheme.
- Move WAL checkpointing into `sql::pool` submodule.
- Order self addresses by addition timestamp.
### Tests
- Remove arbitrary timeouts from `test_4_lowlevel.py`.
- Fix flaky `test_qr_securejoin_broadcast` ([#7937](https://github.com/chatmail/core/pull/7937)).
- Work around `test_sync_broadcast_and_send_message` flakiness.
### Miscellaneous Tasks
- bump version to 2.44.0-dev.
- cargo: bump futures from 0.3.31 to 0.3.32.
- cargo: bump quick-xml from 0.39.0 to 0.39.2.
- cargo: bump criterion from 0.8.1 to 0.8.2.
- cargo: bump tempfile from 3.24.0 to 3.25.0.
- cargo: bump async-imap from 0.11.1 to 0.11.2.
- cargo: bump regex from 1.12.2 to 1.12.3.
- cargo: bump hyper-util from 0.1.19 to 0.1.20.
- cargo: bump anyhow from 1.0.100 to 1.0.102.
- cargo: bump syn from 2.0.114 to 2.0.117.
- cargo: bump proptest from 1.9.0 to 1.10.0.
- cargo: bump strum from 0.27.2 to 0.28.0.
- cargo: bump strum_macros from 0.27.2 to 0.28.0.
- cargo: bump quinn-proto from 0.11.9 to 0.11.14.
## [2.44.0] - 2026-02-27
### Build system
@@ -7821,3 +7906,4 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.42.0]: https://github.com/chatmail/core/compare/v2.41.0..v2.42.0
[2.43.0]: https://github.com/chatmail/core/compare/v2.42.0..v2.43.0
[2.44.0]: https://github.com/chatmail/core/compare/v2.43.0..v2.44.0
[2.45.0]: https://github.com/chatmail/core/compare/v2.44.0..v2.45.0

34
Cargo.lock generated
View File

@@ -1300,7 +1300,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.44.0-dev"
version = "2.46.0-dev"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1410,7 +1410,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.44.0-dev"
version = "2.46.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1431,7 +1431,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.44.0-dev"
version = "2.46.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1447,7 +1447,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.44.0-dev"
version = "2.46.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1476,7 +1476,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.44.0-dev"
version = "2.46.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -3363,6 +3363,12 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lru_time_cache"
version = "0.11.11"
@@ -4682,13 +4688,14 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.9"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.2.16",
"rand 0.8.5",
"getrandom 0.3.3",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -7332,13 +7339,13 @@ dependencies = [
[[package]]
name = "yerpc"
version = "0.6.4"
source = "git+https://github.com/d2weber/yerpc.git?branch=d2weber%2Fc_bindings#3ba217a76b458744192d3a66624463787e6a9eee"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dc24983fbe850227bfc1de89bf8cbfb3e2463afc322e0de2f155c4c23d06445"
dependencies = [
"anyhow",
"async-channel 1.9.0",
"async-lock",
"async-trait",
"convert_case",
"futures",
"futures-util",
"log",
@@ -7351,8 +7358,9 @@ dependencies = [
[[package]]
name = "yerpc_derive"
version = "0.6.4"
source = "git+https://github.com/d2weber/yerpc.git?branch=d2weber%2Fc_bindings#3ba217a76b458744192d3a66624463787e6a9eee"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d8560d021437420316370db865e44c000bf86380b47cf05e49be9d652042bf5"
dependencies = [
"convert_case",
"darling",

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.44.0-dev"
version = "2.46.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.88"
@@ -204,7 +204,7 @@ thiserror = "2"
tokio = "1"
tokio-util = "0.7.18"
tracing-subscriber = "0.3"
yerpc = { git="https://github.com/d2weber/yerpc.git", branch="d2weber/c_bindings" }
yerpc = "0.6.4"
[features]
default = ["vendored"]

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.44.0-dev"
version = "2.46.0-dev"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"

View File

@@ -4612,7 +4612,7 @@ int dc_msg_is_info (const dc_msg_t* msg);
* and also offer a way to fix the encryption, eg. by a button offering a QR scan
* - DC_INFO_WEBXDC_INFO_MESSAGE (32) - Info-message created by webxdc app sending `update.info`
* - DC_INFO_CHAT_E2EE (50) - Info-message for "Chat is end-to-end-encrypted"
* - DC_INFO_GROUP_NAME_CHANGED (70) - Info-message "Description changed", UI should open the profile with the description
* - DC_INFO_GROUP_DESCRIPTION_CHANGED (70) - Info-message "Description changed", UI should open the profile with the description
*
* For the messages that refer to a CONTACT,
* dc_msg_get_info_contact_id() returns the contact ID.
@@ -6755,6 +6755,7 @@ void dc_event_unref(dc_event_t* event);
* UI usually only takes action in case call UI was opened before, otherwise the event should be ignored.
*
* @param data1 (int) msg_id ID of the message referring to the call
* @param data2 (int) 1 if the call was accepted from this device (process).
*/
#define DC_EVENT_INCOMING_CALL_ACCEPTED 2560
@@ -6783,8 +6784,8 @@ void dc_event_unref(dc_event_t* event);
* UI should update the list.
*
* The event is emitted when the transports are modified on another device
* using the JSON-RPC calls `add_or_update_transport`, `add_transport_from_qr`, `delete_transport`
* or `set_config(configured_addr)`.
* using the JSON-RPC calls `add_or_update_transport`, `add_transport_from_qr`, `delete_transport`,
* `set_transport_unpublished` or `set_config(configured_addr)`.
*/
#define DC_EVENT_TRANSPORTS_MODIFIED 2600
@@ -7496,7 +7497,7 @@ void dc_event_unref(dc_event_t* event);
/// "Messages are end-to-end encrypted."
///
/// Used in info messages.
/// Used in info-messages, UI may add smth. as "Tap to learn more."
#define DC_STR_CHAT_PROTECTION_ENABLED 170
/// "Others will only see this group after you sent a first message."
@@ -7579,6 +7580,19 @@ void dc_event_unref(dc_event_t* event);
/// `%1$s` and `%2$s` will both be replaced by the name of the inviter.
#define DC_STR_SECURE_JOIN_CHANNEL_STARTED 203
/// "Channel name changed from %1$s to %2$s."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the old channel name.
/// `%2$s` will be replaced by the new channel name.
#define DC_STR_CHANNEL_NAME_CHANGED 204
/// "Channel image changed."
///
/// Used in status messages.
#define DC_STR_CHANNEL_IMAGE_CHANGED 205
/// "The attachment contains anonymous usage statistics, which help us improve Delta Chat. Thank you!"
///
/// Used as the message body for statistics sent out.
@@ -7617,6 +7631,11 @@ void dc_event_unref(dc_event_t* event);
/// "Chat description changed by %1$s."
#define DC_STR_GROUP_DESCRIPTION_CHANGED_BY_OTHER 241
/// "Messages are end-to-end encrypted."
///
/// Used when creating text for the "Encryption Info" dialogs.
#define DC_STR_MESSAGES_ARE_E2EE 242
/**
* @}
*/

View File

@@ -680,7 +680,6 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::ChatModified(_)
| EventType::ChatDeleted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::IncomingCallAccepted { .. }
| EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. }
| EventType::EventChannelOverflow { .. }
@@ -703,6 +702,9 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
} => status_update_serial.to_u32() as libc::c_int,
EventType::WebxdcRealtimeData { data, .. } => data.len() as libc::c_int,
EventType::IncomingCall { has_video, .. } => *has_video as libc::c_int,
EventType::IncomingCallAccepted {
from_this_device, ..
} => *from_this_device as libc::c_int,
#[allow(unreachable_patterns)]
#[cfg(test)]

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.44.0-dev"
version = "2.46.0-dev"
description = "DeltaChat JSON-RPC API"
edition = "2021"
license = "MPL-2.0"

View File

@@ -1 +0,0 @@
generated

View File

@@ -68,6 +68,7 @@ use self::types::{
},
};
use crate::api::types::chat_list::{get_chat_list_item_by_id, ChatListItemFetchResult};
use crate::api::types::login_param::Transport;
use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath};
#[derive(Debug)]
@@ -156,11 +157,7 @@ impl CommandApi {
}
}
#[rpc(
all_positional,
ts_outdir = "typescript/generated",
c_outdir = "c/generated"
)]
#[rpc(all_positional, ts_outdir = "typescript/generated")]
impl CommandApi {
/// Test function.
async fn sleep(&self, delay: f64) {
@@ -532,6 +529,7 @@ impl CommandApi {
/// from a server encoded in a QR code.
/// - [Self::list_transports()] to get a list of all configured transports.
/// - [Self::delete_transport()] to remove a transport.
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
async fn add_or_update_transport(
&self,
account_id: u32,
@@ -555,9 +553,10 @@ impl CommandApi {
}
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// Use [Self::add_or_update_transport()] to add or change a transport,
/// [Self::set_transport_unpublished()] to publish or unpublish a transport,
/// and [Self::delete_transport()] to delete a transport.
async fn list_transports(&self, account_id: u32) -> Result<Vec<EnteredLoginParam>> {
async fn list_transports(&self, account_id: u32) -> Result<Vec<Transport>> {
let ctx = self.get_context(account_id).await?;
let res = ctx
.list_transports()
@@ -575,6 +574,26 @@ impl CommandApi {
ctx.delete_transport(&addr).await
}
/// Change whether the transport is unpublished.
///
/// Unpublished transports are not advertised to contacts,
/// and self-sent messages are not sent there,
/// so that we don't cause extra messages to the corresponding inbox,
/// but can still receive messages from contacts who don't know the new relay addresses yet.
///
/// The default is true, but when updating,
/// existing secondary transports are set to unpublished,
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
async fn set_transport_unpublished(
&self,
account_id: u32,
addr: String,
unpublished: bool,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.set_transport_unpublished(&addr, unpublished).await
}
/// Signal an ongoing process to stop.
async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;

View File

@@ -441,6 +441,8 @@ pub enum EventType {
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.
@@ -634,9 +636,14 @@ impl From<CoreEventType> for EventType {
place_call_info,
has_video,
},
CoreEventType::IncomingCallAccepted { msg_id, chat_id } => IncomingCallAccepted {
CoreEventType::IncomingCallAccepted {
msg_id,
chat_id,
from_this_device,
} => IncomingCallAccepted {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
from_this_device,
},
CoreEventType::OutgoingCallAccepted {
msg_id,

View File

@@ -4,6 +4,16 @@ use serde::Deserialize;
use serde::Serialize;
use yerpc::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Transport {
/// The login data entered by the user.
pub param: EnteredLoginParam,
/// Whether this transport is set to 'unpublished'.
/// See `set_transport_unpublished` / `setTransportUnpublished` for details.
pub is_unpublished: bool,
}
/// Login parameters entered by the user.
///
/// Usually it will be enough to only set `addr` and `password`,
@@ -56,6 +66,15 @@ pub struct EnteredLoginParam {
pub oauth2: Option<bool>,
}
impl From<dc::Transport> for Transport {
fn from(transport: dc::Transport) -> Self {
Transport {
param: transport.param.into(),
is_unpublished: transport.is_unpublished,
}
}
}
impl From<dc::EnteredLoginParam> for EnteredLoginParam {
fn from(param: dc::EnteredLoginParam) -> Self {
let imap_security: Socket = param.imap.security.into();

View File

@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.44.0-dev"
"version": "2.46.0-dev"
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "2.44.0-dev"
version = "2.46.0-dev"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/chatmail/core"

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.44.0-dev"
version = "2.46.0-dev"
license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [

View File

@@ -1,4 +1,5 @@
import argparse
import functools
import os
import re
import sys
@@ -186,6 +187,7 @@ class futuremethod: # noqa: N801
"""Decorator for async methods."""
def __init__(self, func):
functools.update_wrapper(self, func)
self._func = func
def __get__(self, instance, owner=None):

View File

@@ -99,7 +99,7 @@ class ACFactory:
ac.remove()
ac_clone = self.get_unconfigured_account()
for transport in transports:
ac_clone.add_or_update_transport(transport)
ac_clone.add_or_update_transport(transport["param"])
ac_clone.bring_online()
return ac_clone

View File

@@ -22,8 +22,8 @@ def test_add_second_address(acfactory) -> None:
account.add_transport_from_qr(qr)
assert len(account.list_transports()) == 3
first_addr = account.list_transports()[0]["addr"]
second_addr = account.list_transports()[1]["addr"]
first_addr = account.list_transports()[0]["param"]["addr"]
second_addr = account.list_transports()[1]["param"]["addr"]
# Cannot delete the first address.
with pytest.raises(JsonRpcError):
@@ -90,7 +90,7 @@ def test_change_address(acfactory) -> None:
assert old_alice_addr in alice_vcard
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
new_alice_addr = alice.list_transports()[1]["addr"]
new_alice_addr = alice.list_transports()[1]["param"]["addr"]
with pytest.raises(JsonRpcError):
# Cannot use the address that is not
# configured for any transport.
@@ -179,7 +179,7 @@ def test_reconfigure_transport(acfactory) -> None:
account.set_config("mvbox_move", "1")
[transport] = account.list_transports()
account.add_or_update_transport(transport)
account.add_or_update_transport(transport["param"])
# Reconfiguring the transport should not reset
# the settings as if when configuring the first transport.
@@ -215,27 +215,30 @@ def test_transport_synchronization(acfactory, log) -> None:
log.section("ac1 clone removes second transport")
[transport1, transport2, transport3] = ac1_clone.list_transports()
addr3 = transport3["addr"]
ac1_clone.delete_transport(transport2["addr"])
addr3 = transport3["param"]["addr"]
ac1_clone.delete_transport(transport2["param"]["addr"])
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1)
[transport1, transport3] = ac1.list_transports()
log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport3["addr"])
ac1.set_config("configured_addr", transport3["param"]["addr"])
# One event for updated `add_timestamp` of the new primary transport,
# one event for the `configured_addr` update.
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
[transport1, transport3] = ac1_clone.list_transports()
assert ac1_clone.get_config("configured_addr") == addr3
log.section("ac1 removes the first transport")
ac1.delete_transport(transport1["addr"])
ac1.delete_transport(transport1["param"]["addr"])
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1_clone)
[transport3] = ac1_clone.list_transports()
assert transport3["addr"] == addr3
assert transport3["param"]["addr"] == addr3
assert ac1_clone.get_config("configured_addr") == addr3
ac2_chat = ac2.create_chat(ac1)
@@ -259,13 +262,13 @@ def test_transport_sync_new_as_primary(acfactory, log) -> None:
[transport1, transport2] = ac1_transports
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert len(ac1_clone.list_transports()) == 2
assert ac1_clone.get_config("configured_addr") == transport1["addr"]
assert ac1_clone.get_config("configured_addr") == transport1["param"]["addr"]
log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport2["addr"])
ac1.set_config("configured_addr", transport2["param"]["addr"])
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert ac1_clone.get_config("configured_addr") == transport2["addr"]
assert ac1_clone.get_config("configured_addr") == transport2["param"]["addr"]
log.section("ac1_clone receives a message via the new primary transport")
ac1_chat = ac1.create_chat(bob)
@@ -285,7 +288,7 @@ def test_recognize_self_address(acfactory) -> None:
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
new_alice_addr = alice.list_transports()[1]["addr"]
new_alice_addr = alice.list_transports()[1]["param"]["addr"]
alice.set_config("configured_addr", new_alice_addr)
bob_chat.send_text("Hello!")
@@ -308,18 +311,17 @@ def test_transport_limit(acfactory) -> None:
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
second_addr = account.list_transports()[1]["addr"]
second_addr = account.list_transports()[1]["param"]["addr"]
account.delete_transport(second_addr)
# test that adding a transport after deleting one works again
account.add_transport_from_qr(qr)
def test_message_info_imap_urls(acfactory, log) -> None:
def test_message_info_imap_urls(acfactory) -> None:
"""Test that message info contains IMAP URLs of where the message was received."""
alice, bob = acfactory.get_online_accounts(2)
log.section("Alice adds ac1 clone removes second transport")
qr = acfactory.get_account_qr()
for i in range(3):
alice.add_transport_from_qr(qr)
@@ -327,9 +329,6 @@ def test_message_info_imap_urls(acfactory, log) -> None:
for _ in range(i + 1):
alice.bring_online()
new_alice_addr = alice.list_transports()[2]["addr"]
alice.set_config("configured_addr", new_alice_addr)
# Enable multi-device mode so messages are not deleted immediately.
alice.set_config("bcc_self", "1")
@@ -337,12 +336,51 @@ def test_message_info_imap_urls(acfactory, log) -> None:
# This is where he will send the message.
bob_chat = bob.create_chat(alice)
# Alice changes the transport again.
alice.set_config("configured_addr", alice.list_transports()[3]["addr"])
# Alice switches to another transport and removes the rest of the transports.
new_alice_addr = alice.list_transports()[1]["param"]["addr"]
alice.set_config("configured_addr", new_alice_addr)
removed_addrs = []
for transport in alice.list_transports():
if transport["param"]["addr"] != new_alice_addr:
alice.delete_transport(transport["param"]["addr"])
removed_addrs.append(transport["param"]["addr"])
alice.stop_io()
alice.start_io()
bob_chat.send_text("Hello!")
msg = alice.wait_for_incoming_msg()
for alice_transport in alice.list_transports():
addr = alice_transport["addr"]
assert (addr == new_alice_addr) == (addr in msg.get_info())
msg_info = msg.get_info()
assert new_alice_addr in msg_info
for removed_addr in removed_addrs:
assert removed_addr not in msg_info
assert f"{new_alice_addr}/INBOX" in msg_info
def test_remove_primary_transport(acfactory) -> None:
"""Test that after removing the primary relay, Alice can still receive messages."""
alice, bob = acfactory.get_online_accounts(2)
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
alice.bring_online()
bob_chat = bob.create_chat(alice)
alice.create_chat(bob)
# Alice changes the transport.
[transport1, transport2] = alice.list_transports()
alice.set_config("configured_addr", transport2["param"]["addr"])
bob_chat.send_text("Hello!")
msg1 = alice.wait_for_incoming_msg().get_snapshot()
assert msg1.text == "Hello!"
# Alice deletes the first transport.
alice.delete_transport(transport1["param"]["addr"])
alice.stop_io()
alice.start_io()
bob_chat.send_text("Hello again!")
msg2 = alice.wait_for_incoming_msg().get_snapshot()
assert msg2.text == "Hello again!"

View File

@@ -88,7 +88,7 @@ def test_lowercase_address(acfactory) -> None:
assert account.is_configured()
assert addr_upper != addr
assert account.get_config("configured_addr") == addr
assert account.list_transports()[0]["addr"] == addr
assert account.list_transports()[0]["param"]["addr"] == addr
param = account.get_info()["used_transport_settings"]
assert addr in param
@@ -138,7 +138,7 @@ def test_list_transports(acfactory) -> None:
)
transports = account.list_transports()
assert len(transports) == 1
params = transports[0]
params = transports[0]["param"]
assert params["addr"] == addr
assert params["password"] == password
assert params["imapUser"] == addr

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.44.0-dev"
version = "2.46.0-dev"
description = "DeltaChat JSON-RPC server"
edition = "2021"
readme = "README.md"

View File

@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.44.0-dev"
"version": "2.46.0-dev"
}

View File

@@ -1,101 +0,0 @@
#include <cstdio>
#include <cstdlib>
#include <string>
#include <iostream>
#include "deltachat-jsonrpc/c/generated/dc_json_cjson.h"
#include "deltachat-jsonrpc/c/generated/rpc.hpp"
#include "../chatmail-core-2.43.0/deltachat.h"
struct CffiTransport {
dc_jsonrpc_instance_t* jsonrpc;
char* buf_ = nullptr;
CffiTransport(dc_accounts_t* accounts) : jsonrpc(dc_jsonrpc_init(accounts)) {if (!jsonrpc) {std::abort();}}
CffiTransport(CffiTransport&& o) : jsonrpc(o.jsonrpc), buf_(o.buf_) { o.jsonrpc = nullptr; o.buf_ = nullptr; }
CffiTransport& operator=(CffiTransport&&) = delete;
CffiTransport(const CffiTransport&) = delete;
void send(const char* json) { dc_jsonrpc_request(jsonrpc, json); }
const char* read() {
if (buf_) { dc_str_unref(buf_); buf_ = nullptr; }
buf_ = dc_jsonrpc_next_response(jsonrpc);
if (!buf_) { dc_jsonrpc_unref(jsonrpc); jsonrpc = nullptr; return nullptr; }
return buf_;
}
~CffiTransport() { if (buf_) dc_str_unref(buf_); if (jsonrpc) dc_jsonrpc_unref(jsonrpc); }
void close() { send("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"get_system_info\"})\")"); }
};
// struct StdioTransport {
// std::string buf_;
// void send(const char* json) { printf("%s\n", json); fflush(stdout); }
// const char* read() {
// if (!std::getline(std::cin, buf_)) return nullptr;
// return buf_.c_str();
// }
// };
// struct CffiTransport {
// dc_jsonrpc_instance_t* jsonrpc;
// CffiTransport(dc_accounts_t* accounts) : jsonrpc(dc_jsonrpc_init(accounts)) {}
// void send(const char* json) { dc_jsonrpc_request(jsonrpc, json); }
// std::string read() {
// char* r = dc_jsonrpc_next_response(jsonrpc);
// if (!r) {dc_jsonrpc_unref(jsonrpc); return {};}
// std::string s(r);
// dc_str_unref(r);
// return s;
// }
// void close() { }
// };
// ;
void logger(std::string msg) {
std::cerr << msg << std::endl;
}
int main() {
auto* raw_accounts = dc_accounts_new("my-accounts", 1);
if (!raw_accounts) return -1;
dc::Rpc<CffiTransport> rpc(CffiTransport{raw_accounts}, logger);
dc_accounts_unref(raw_accounts);
printf("arch: %s\n", rpc.get_system_info().find("arch"));
auto account_ids = rpc.get_all_account_ids();
printf("size 1: %zu\n", account_ids.size());
auto acc_id = account_ids.size() == 0 ? rpc.add_account() : account_ids.view()[0];
printf("acc_id: %u\n", acc_id);
auto a = dc::ArrayString("is_chatmail", "addr");
printf("a %zu\n", a.size());
printf("a %i\n", bool(a));
auto config = rpc.batch_get_config(acc_id, std::move(a));
printf("s: %zu\n", config.size());
printf("c: %s\n", config._c->keys[0]);
printf("c: %s\n", config._c->values[0]);
printf("c: %s\n", config._c->keys[1]);
printf("c: %s\n", config._c->values[1]);
printf("c: %s\n", config.find("addr")._c);
auto chat_id = rpc.secure_join(acc_id, "https://i.delta.chat/#AFE2503F3BDC9058CBEAB8AEBAA028AE86816AD5&v=3&i=_CmYTfzPFjM9JQV_R05mK4Qg&s=1F7dmW_qhOGytyFXUbo6who5&a=uxyjz202n%40nine.testrun.org&n=1");
// auto chat_id = 12;
auto e = rpc.get_next_event_batch();
for (size_t i = 0; i < e.size(); ++i) {
printf("kind: %i\n", e[i].event()._c->kind);
}
// {
// printf("kind: %i\n", rpc.get_next_event().event()._c->kind);
// }
auto y = rpc.send_msg(acc_id, chat_id, dc::MessageData(string_new("hi"), {}, {}, {}, {}, {}, {}, {}, {}));
// rpc.sleep(10);
auto chat = rpc.get_full_chat_by_id(acc_id, chat_id);
printf("canSend: %s\n", chat.canSend() ? "yes" : "no");
printf("isContactRequest: %s\n", chat.isContactRequest()? "yes" : "no");
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.44.0-dev"
version = "2.46.0-dev"
license = "MPL-2.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"

View File

@@ -1 +1 @@
2026-02-27
2026-03-14

View File

@@ -7,7 +7,7 @@ set -euo pipefail
#
# Avoid using rustup here as it depends on reading /proc/self/exe and
# has problems running under QEMU.
RUST_VERSION=1.93.0
RUST_VERSION=1.94.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu

View File

@@ -11,7 +11,7 @@ use crate::context::{Context, WeakContext};
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::log::warn;
use crate::message::{Message, MsgId, Viewtype};
use crate::message::{Message, MsgId, Viewtype, markseen_msgs};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::net::dns::lookup_host_with_cache;
use crate::param::Param;
@@ -249,6 +249,7 @@ impl Context {
if chat.is_contact_request() {
chat.id.accept(self).await?;
}
markseen_msgs(self, vec![call_id]).await?;
// send an acceptance message around: to the caller as well as to the other devices of the callee
let mut msg = Message {
@@ -265,6 +266,7 @@ impl Context {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
from_this_device: true,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
@@ -283,6 +285,7 @@ impl Context {
if !call.is_accepted() {
if call.is_incoming() {
call.mark_as_ended(self).await?;
markseen_msgs(self, vec![call_id]).await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
} else {
@@ -430,6 +433,7 @@ impl Context {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
from_this_device: false,
});
} else {
let accept_call_info = mime_message

View File

@@ -2,6 +2,7 @@ use super::*;
use crate::chat::forward_msgs;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::message::MessageState;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
@@ -115,9 +116,28 @@ async fn accept_call() -> Result<CallSetup> {
// Bob accepts the incoming call
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
.await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Incoming video call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.get_matching(|evt| {
matches!(
evt,
EventType::IncomingCallAccepted {
from_this_device: true,
..
}
)
})
.await;
let sent2 = bob.pop_sent_msg().await;
let info = bob
@@ -131,7 +151,15 @@ async fn accept_call() -> Result<CallSetup> {
bob2.recv_msg_trash(&sent2).await;
assert_text(&bob, bob_call.id, "Incoming video call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.get_matching(|evt| {
matches!(
evt,
EventType::IncomingCallAccepted {
from_this_device: false,
..
}
)
})
.await;
let info = bob2
.load_call_by_id(bob2_call.id)
@@ -200,9 +228,20 @@ async fn test_accept_call_callee_ends() -> Result<()> {
bob2_call,
..
} = accept_call().await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob has accepted the call and also ends it
bob.end_call(bob_call.id).await?;
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -328,8 +367,18 @@ async fn test_callee_rejects_call() -> Result<()> {
} = setup_call().await?;
// Bob has accepted Alice before, but does not want to talk with Alice
bob_call.chat_id.accept(&bob).await?;
bob.end_call(bob_call.id).await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Declined call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -370,6 +419,35 @@ async fn test_callee_rejects_call() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_callee_sees_contact_request_call() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
alice
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
.await?;
let sent1 = alice.pop_sent_msg().await;
let bob_call = bob.recv_msg(&sent1).await;
// Bob can't end_call() because the contact request isn't accepted, but he can mark the call as
// seen.
markseen_msgs(bob, vec![bob_call.id]).await?;
assert_eq!(bob_call.id.get_state(bob).await?, MessageState::InSeen);
// Bob sends an MDN only to self so that an unaccepted contact can't know anything.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, ContactId::SELF)
)
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_caller_cancels_call() -> Result<()> {
// Alice calls Bob

View File

@@ -1,7 +1,7 @@
//! # Chat module.
use std::cmp;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt;
use std::io::Cursor;
use std::marker::Sync;
@@ -42,6 +42,7 @@ use crate::message::{self, Message, MessageState, MsgId, Viewtype};
use crate::mimefactory::{MimeFactory, RenderedEmail};
use crate::mimeparser::SystemMessage;
use crate::param::{Param, Params};
use crate::pgp::addresses_from_public_key;
use crate::receive_imf::ReceivedMsg;
use crate::smtp::{self, send_msg_to_smtp};
use crate::stock_str;
@@ -475,7 +476,7 @@ impl ChatId {
/// Adds message "Messages are end-to-end encrypted".
pub(crate) async fn add_e2ee_notice(self, context: &Context, timestamp: i64) -> Result<()> {
let text = stock_str::messages_e2e_encrypted(context).await;
let text = stock_str::messages_e2ee_info_msg(context).await;
add_info_msg_with_cmd(
context,
self,
@@ -1157,7 +1158,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
return Ok(stock_str::encr_none(context).await);
}
let mut ret = stock_str::messages_e2e_encrypted(context).await + "\n";
let mut ret = stock_str::messages_are_e2ee(context).await + "\n";
for &contact_id in get_chat_contacts(context, self)
.await?
@@ -1174,8 +1175,13 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
let fingerprint = contact
.fingerprint()
.context("Contact does not have a fingerprint in encrypted chat")?;
if contact.public_key(context).await?.is_some() {
ret += &format!("\n{addr}\n{fingerprint}\n");
if let Some(public_key) = contact.public_key(context).await? {
if let Some(relay_addrs) = addresses_from_public_key(&public_key) {
let relays = relay_addrs.join(",");
ret += &format!("\n{addr}({relays})\n{fingerprint}\n");
} else {
ret += &format!("\n{addr}\n{fingerprint}\n");
}
} else {
ret += &format!("\n{addr}\n(key missing)\n{fingerprint}\n");
}
@@ -2731,7 +2737,6 @@ async fn prepare_send_msg(
chat_id.unarchive_if_not_muted(context, msg.state).await?;
}
chat.prepare_msg_raw(context, msg, update_msg_id).await?;
let row_ids = create_send_msg_jobs(context, msg)
.await
.context("Failed to create send jobs")?;
@@ -2838,19 +2843,12 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
let lowercase_from = from.to_lowercase();
recipients.retain(|x| x.to_lowercase() != lowercase_from);
if context.get_config_bool(Config::BccSelf).await?
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage
// Default Webxdc integrations are hidden messages and must not be sent out:
if (msg.param.get_int(Param::WebxdcIntegration).is_some() && msg.hidden)
// This may happen eg. for groups with only SELF and bcc_self disabled:
|| (!context.get_config_bool(Config::BccSelf).await? && recipients.is_empty())
{
smtp::add_self_recipients(context, &mut recipients, needs_encryption).await?;
}
// Default Webxdc integrations are hidden messages and must not be sent out
if msg.param.get_int(Param::WebxdcIntegration).is_some() && msg.hidden {
recipients.clear();
}
if recipients.is_empty() {
// may happen eg. for groups with only SELF and bcc_self disabled
info!(
context,
"Message {} has no recipient, skipping smtp-send.", msg.id
@@ -2889,6 +2887,12 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
);
}
if context.get_config_bool(Config::BccSelf).await?
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage
{
smtp::add_self_recipients(context, &mut recipients, rendered_msg.is_encrypted).await?;
}
if needs_encryption && !rendered_msg.is_encrypted {
/* unrecoverable */
message::set_msg_failed(
@@ -4322,8 +4326,11 @@ async fn rename_ex(
&& sanitize_single_line(&chat.name) != new_name
{
msg.viewtype = Viewtype::Text;
msg.text =
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await;
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_name_changed(context, &chat.name, &new_name).await
} else {
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await
};
msg.param.set_cmd(SystemMessage::GroupNameChanged);
if !chat.name.is_empty() {
msg.param.set(Param::Arg, &chat.name);
@@ -4384,7 +4391,11 @@ pub async fn set_chat_profile_image(
if new_image.is_empty() {
chat.param.remove(Param::ProfileImage);
msg.param.remove(Param::Arg);
msg.text = stock_str::msg_grp_img_deleted(context, ContactId::SELF).await;
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_img_changed(context).await
} else {
stock_str::msg_grp_img_deleted(context, ContactId::SELF).await
};
} else {
let mut image_blob = BlobObject::create_and_deduplicate(
context,
@@ -4394,7 +4405,11 @@ pub async fn set_chat_profile_image(
image_blob.recode_to_avatar_size(context).await?;
chat.param.set(Param::ProfileImage, image_blob.as_name());
msg.param.set(Param::Arg, image_blob.as_name());
msg.text = stock_str::msg_grp_img_changed(context, ContactId::SELF).await;
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_img_changed(context).await
} else {
stock_str::msg_grp_img_changed(context, ContactId::SELF).await
};
}
chat.update_param(context).await?;
if chat.is_promoted() {
@@ -5031,18 +5046,18 @@ async fn set_contacts_by_fingerprints(
matches!(chat.typ, Chattype::Group | Chattype::OutBroadcast),
"{id} is not a group or broadcast",
);
let mut contacts = HashSet::new();
let mut contacts = BTreeSet::new();
for (fingerprint, addr) in fingerprint_addrs {
let contact = Contact::add_or_lookup_ex(context, "", addr, fingerprint, Origin::Hidden)
.await?
.0;
contacts.insert(contact);
}
let contacts_old = HashSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
let contacts_old = BTreeSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
if contacts == contacts_old {
return Ok(());
}
context
let broadcast_contacts_added = context
.sql
.transaction(move |transaction| {
// For broadcast channels, we only add members,
@@ -5059,12 +5074,31 @@ async fn set_contacts_by_fingerprints(
let mut statement = transaction.prepare(
"INSERT OR IGNORE INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)",
)?;
let mut broadcast_contacts_added = Vec::new();
for contact_id in &contacts {
statement.execute((id, contact_id))?;
if statement.execute((id, contact_id))? > 0 && chat.typ == Chattype::OutBroadcast {
broadcast_contacts_added.push(*contact_id);
}
}
Ok(())
Ok(broadcast_contacts_added)
})
.await?;
let timestamp = smeared_time(context);
for added_id in broadcast_contacts_added {
let msg = stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED).await;
add_info_msg_with_cmd(
context,
id,
&msg,
SystemMessage::MemberAddedToGroup,
Some(timestamp),
timestamp,
None,
Some(ContactId::SELF),
Some(added_id),
)
.await?;
}
context.emit_event(EventType::ChatModified(id));
Ok(())
}

View File

@@ -2997,18 +2997,41 @@ async fn test_broadcast_recipients_sync1() -> Result<()> {
alice2.recv_msg_trash(&request_with_auth).await;
let member_added = alice1.pop_sent_msg().await;
let a2_member_added = alice2.recv_msg(&member_added).await;
let a2_charlie_added = alice2.recv_msg(&member_added).await;
let _c_member_added = charlie.recv_msg(&member_added).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_charlie_added.id);
// Alice1 will now sync the full member list to Alice2:
sync(alice1, alice2).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_member_added.id);
let a2_bob_contact = alice2.add_or_lookup_contact_id(bob).await;
let a2_charlie_contact = alice2.add_or_lookup_contact_id(charlie).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
let msg_id = a2_chatlist.get_msg_id(0)?.unwrap();
let a2_bob_added = Message::load_from_db(alice2, msg_id).await?;
assert_ne!(a2_bob_added.id, a2_charlie_added.id);
assert_eq!(
a2_bob_added.text,
stock_str::msg_add_member_local(alice2, a2_bob_contact, ContactId::UNDEFINED).await
);
assert_eq!(a2_bob_added.from_id, ContactId::SELF);
assert_eq!(
a2_bob_added.param.get_cmd(),
SystemMessage::MemberAddedToGroup
);
assert_eq!(
ContactId::new(
a2_bob_added
.param
.get_int(Param::ContactAddedRemoved)
.unwrap()
.try_into()
.unwrap()
),
a2_bob_contact
);
let a2_chat_members = get_chat_contacts(alice2, a2_member_added.chat_id).await?;
let a2_chat_members = get_chat_contacts(alice2, a2_charlie_added.chat_id).await?;
assert!(a2_chat_members.contains(&a2_bob_contact));
assert!(a2_chat_members.contains(&a2_charlie_contact));
assert_eq!(a2_chat_members.len(), 2);
@@ -3114,7 +3137,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupNameChanged);
assert_eq!(
rcvd.text,
r#"Group name changed from "My Channel" to "New Channel name" by Alice."#
r#"Channel name changed from "My Channel" to "New Channel name"."#
);
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
assert_eq!(bob_chat.name, "New Channel name");
@@ -3131,7 +3154,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
let rcvd = bob.recv_msg(&sent).await;
assert!(rcvd.get_override_sender_name().is_none());
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupImageChanged);
assert_eq!(rcvd.text, "Group image changed by Alice.");
assert_eq!(rcvd.text, "Channel image changed.");
assert_eq!(rcvd.chat_id, bob_chat.id);
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
@@ -3833,6 +3856,7 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
tcm.section("Now, Alice's fingerprint changes");
alice.sql.execute("DELETE FROM keypairs", ()).await?;
*alice.self_public_key.lock().await = None;
alice
.sql
.execute("DELETE FROM config WHERE keyname='key_id'", ())
@@ -3843,14 +3867,20 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
.self_fingerprint
.take();
tcm.section(
"Alice sends a message, which is not put into the broadcast chat but into a 1:1 chat",
);
tcm.section("Alice sends a message, which is trashed");
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(rcvd.text, "Hi");
let bob_alice_chat_id = bob.get_chat(alice).await.id;
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
bob.recv_msg_trash(&sent).await;
let EventType::Warning(warning) = bob
.evtracker
.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(())
}
@@ -3885,7 +3915,7 @@ async fn test_sync_broadcast_avatar_and_name() -> Result<()> {
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
assert_eq!(
rcvd.text,
r#"You changed group name from "foo" to "New name"."#
r#"Channel name changed from "foo" to "New name"."#
);
let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?;
@@ -3899,7 +3929,7 @@ async fn test_sync_broadcast_avatar_and_name() -> Result<()> {
let rcvd = alice1.recv_msg(&sent).await;
assert_eq!(rcvd.chat_id, a1_broadcast_id);
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupImageChanged);
assert_eq!(rcvd.text, "You changed the group image.");
assert_eq!(rcvd.text, "Channel image changed.");
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
let avatar = a1_broadcast_chat.get_profile_image(alice1).await?.unwrap();
@@ -3919,6 +3949,7 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
let grpid = "grpid";
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");
let alice_chat_id = create_out_broadcast_ex(
@@ -3942,6 +3973,7 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
)
.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
.send_text(alice_chat_id, "Symmetrically encrypted message")
@@ -4015,7 +4047,7 @@ async fn test_chat_get_encryption_info() -> Result<()> {
chat_id.get_encryption_info(alice).await?,
"Messages are end-to-end encrypted.\n\
\n\
bob@example.net\n\
bob@example.net(bob@example.net)\n\
CCCB 5AA9 F6E1 141C 9431\n\
65F1 DB18 B18C BCF7 0487"
);
@@ -4025,11 +4057,11 @@ async fn test_chat_get_encryption_info() -> Result<()> {
chat_id.get_encryption_info(alice).await?,
"Messages are end-to-end encrypted.\n\
\n\
fiona@example.net\n\
fiona@example.net(fiona@example.net)\n\
C8BA 50BF 4AC1 2FAF 38D7\n\
F657 DDFC 8E9F 3C79 9195\n\
\n\
bob@example.net\n\
bob@example.net(bob@example.net)\n\
CCCB 5AA9 F6E1 141C 9431\n\
65F1 DB18 B18C BCF7 0487"
);
@@ -4701,6 +4733,10 @@ async fn test_sync_broadcast_and_send_message() -> Result<()> {
vec![a2b_contact_id]
);
// alice2's smeared clock may be behind alice1's one, so we need to work around "hi" appearing
// before "You joined the channel." for bob. alice1 makes 3 more calls of
// create_smeared_timestamp() than alice2 does as of 2026-03-10.
SystemTime::shift(Duration::from_secs(3));
tcm.section("Alice's second device sends a message to the channel");
let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await;
let msg = bob.recv_msg(&sent_msg).await;
@@ -4765,7 +4801,7 @@ async fn test_sync_name() -> Result<()> {
assert_eq!(rcvd.to_id, ContactId::SELF);
assert_eq!(
rcvd.text,
"You changed group name from \"Channel\" to \"Broadcast channel 42\"."
"Channel name changed from \"Channel\" to \"Broadcast channel 42\"."
);
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)

View File

@@ -19,7 +19,7 @@ use crate::log::LogExt;
use crate::mimefactory::RECOMMENDED_FILE_SIZE;
use crate::provider::Provider;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::get_abs_path;
use crate::tools::{get_abs_path, time};
use crate::transport::{ConfiguredLoginParam, add_pseudo_transport, send_sync_transports};
use crate::{constants, stats};
@@ -828,6 +828,22 @@ impl Context {
(addr,),
)?;
// Update the timestamp for the primary transport
// so it becomes the first in `get_all_self_addrs()` list
// and the list of relays distributed in the public key.
// This ensures that messages will be sent
// to the primary relay by the contacts
// and will be fetched in background_fetch()
// which only fetches from the primary transport.
transaction
.execute(
"UPDATE transports SET add_timestamp=?, is_published=1 WHERE addr=?",
(time(), addr),
)
.context(
"Failed to update add_timestamp for the new primary transport",
)?;
// Clean up SMTP and IMAP APPEND queue.
//
// The messages in the queue have a different
@@ -944,12 +960,33 @@ impl Context {
Ok(())
}
/// Returns the primary self address followed by all secondary ones.
/// Returns all self addresses, newest first.
pub(crate) async fn get_all_self_addrs(&self) -> Result<Vec<String>> {
let primary_addrs = self.get_config(Config::ConfiguredAddr).await?.into_iter();
let secondary_addrs = self.get_secondary_self_addrs().await?.into_iter();
self.sql
.query_map_vec(
"SELECT addr FROM transports ORDER BY add_timestamp DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
Ok(primary_addrs.chain(secondary_addrs).collect())
/// Returns all published self addresses, newest first.
/// See `[Context::set_transport_unpublished]`
pub(crate) async fn get_published_self_addrs(&self) -> Result<Vec<String>> {
self.sql
.query_map_vec(
"SELECT addr FROM transports WHERE is_published=1 ORDER BY add_timestamp DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
/// Returns all secondary self addresses.
@@ -960,6 +997,23 @@ impl Context {
}).await
}
/// Returns all published secondary self addresses.
/// See `[Context::set_transport_unpublished]`
pub(crate) async fn get_published_secondary_self_addrs(&self) -> Result<Vec<String>> {
self.sql
.query_map_vec(
"SELECT addr FROM transports
WHERE is_published=1
AND addr NOT IN (SELECT value FROM config WHERE keyname='configured_addr')",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
/// Returns the primary self address.
/// Returns an error if no self addr is configured.
pub async fn get_primary_self_addr(&self) -> Result<String> {

View File

@@ -28,8 +28,8 @@ use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
use crate::context::Context;
use crate::imap::Imap;
use crate::log::warn;
use crate::login_param::EnteredCertificateChecks;
pub use crate::login_param::EnteredLoginParam;
use crate::login_param::{EnteredCertificateChecks, Transport};
use crate::message::Message;
use crate::net::proxy::ProxyConfig;
use crate::oauth2::get_oauth2_addr;
@@ -110,6 +110,7 @@ impl Context {
/// from a server encoded in a QR code.
/// - [Self::list_transports()] to get a list of all configured transports.
/// - [Self::delete_transport()] to remove a transport.
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
pub async fn add_or_update_transport(&self, param: &mut EnteredLoginParam) -> Result<()> {
self.stop_io().await;
let result = self.add_transport_inner(param).await;
@@ -188,14 +189,22 @@ impl Context {
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
pub async fn list_transports(&self) -> Result<Vec<EnteredLoginParam>> {
pub async fn list_transports(&self) -> Result<Vec<Transport>> {
let transports = self
.sql
.query_map_vec("SELECT entered_param FROM transports", (), |row| {
let entered_param: String = row.get(0)?;
let transport: EnteredLoginParam = serde_json::from_str(&entered_param)?;
Ok(transport)
})
.query_map_vec(
"SELECT entered_param, is_published FROM transports",
(),
|row| {
let param: String = row.get(0)?;
let param: EnteredLoginParam = serde_json::from_str(&param)?;
let is_published: bool = row.get(1)?;
Ok(Transport {
param,
is_unpublished: !is_published,
})
},
)
.await?;
Ok(transports)
@@ -261,6 +270,40 @@ impl Context {
Ok(())
}
/// Change whether the transport is unpublished.
///
/// Unpublished transports are not advertised to contacts,
/// and self-sent messages are not sent there,
/// so that we don't cause extra messages to the corresponding inbox,
/// but can still receive messages from contacts who don't know the new relay addresses yet.
///
/// The default is true, but when updating,
/// existing secondary transports are set to unpublished,
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
pub async fn set_transport_unpublished(&self, addr: &str, unpublished: bool) -> Result<()> {
// We need to update the timestamp so that the key's timestamp changes
// and is recognized as newer by our peers
self.sql
.transaction(|trans| {
let primary_addr: String = trans.query_row(
"SELECT value FROM config WHERE keyname='configured_addr'",
(),
|row| row.get(0),
)?;
if primary_addr == addr && unpublished {
bail!("Can't set primary relay as unpublished");
}
trans.execute(
"UPDATE transports SET is_published=?, add_timestamp=? WHERE addr=?",
(!unpublished, time(), addr),
)?;
Ok(())
})
.await?;
send_sync_transports(self).await?;
Ok(())
}
async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> {
info!(self, "Configure ...");
@@ -549,9 +592,6 @@ async fn get_configured_param(
async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'static Provider>> {
progress!(ctx, 1);
let ctx2 = ctx.clone();
let update_device_chats_handle = task::spawn(async move { ctx2.update_device_chats().await });
let configured_param = get_configured_param(ctx, param).await?;
let proxy_config = ProxyConfig::load(ctx).await?;
let strict_tls = configured_param.strict_tls(proxy_config.is_some());
@@ -642,7 +682,9 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
ctx.scheduler.interrupt_inbox().await;
progress!(ctx, 940);
update_device_chats_handle.await??;
ctx.update_device_chats()
.await
.context("Failed to update device chats")?;
ctx.sql.set_raw_config_bool("configured", true).await?;
ctx.emit_event(EventType::AccountsItemChanged);

View File

@@ -35,6 +35,7 @@ use crate::log::{LogExt, warn};
use crate::message::MessageState;
use crate::mimeparser::AvatarAction;
use crate::param::{Param, Params};
use crate::pgp::{addresses_from_public_key, merge_openpgp_certificates};
use crate::sync::{self, Sync::*};
use crate::tools::{SystemTime, duration_to_str, get_abs_path, normalize_text, time, to_lowercase};
use crate::{chat, chatlist_events, ensure_and_debug_assert_ne, stock_str};
@@ -314,6 +315,67 @@ pub async fn make_vcard(context: &Context, contacts: &[ContactId]) -> Result<Str
.to_string())
}
/// Imports public key into the public key store.
///
/// They key may come from Autocrypt header,
/// Autocrypt-Gossip header or a vCard.
///
/// If the key with the same fingerprint already exists,
/// it is updated by merging the new key.
pub(crate) async fn import_public_key(
context: &Context,
public_key: &SignedPublicKey,
) -> Result<()> {
public_key
.verify_bindings()
.context("Attempt to import broken public key")?;
let fingerprint = public_key.dc_fingerprint().hex();
let merged_public_key;
let merged_public_key_ref = if let Some(public_key_bytes) = context
.sql
.query_row_optional(
"SELECT public_key
FROM public_keys
WHERE fingerprint=?",
(&fingerprint,),
|row| {
let bytes: Vec<u8> = row.get(0)?;
Ok(bytes)
},
)
.await?
{
let old_public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
merged_public_key = merge_openpgp_certificates(public_key.clone(), old_public_key)
.context("Failed to merge public keys")?;
&merged_public_key
} else {
public_key
};
let inserted = context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO UPDATE SET public_key=excluded.public_key
WHERE public_key!=excluded.public_key",
(&fingerprint, merged_public_key_ref.to_bytes()),
)
.await?;
if inserted > 0 {
info!(
context,
"Saved key with fingerprint {fingerprint} from the Autocrypt header"
);
}
Ok(())
}
/// Imports contacts from the given vCard.
///
/// Returns the ids of successfully processed contacts in the order they appear in `vcard`,
@@ -352,23 +414,14 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
.ok()
});
let fingerprint;
if let Some(public_key) = key {
fingerprint = public_key.dc_fingerprint().hex();
context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO NOTHING",
(&fingerprint, public_key.to_bytes()),
)
.await?;
let fingerprint = if let Some(public_key) = key {
import_public_key(context, &public_key)
.await
.context("Failed to import public key from vCard")?;
public_key.dc_fingerprint().hex()
} else {
fingerprint = String::new();
}
String::new()
};
let (id, modified) =
match Contact::add_or_lookup_ex(context, &contact.authname, &addr, &fingerprint, origin)
@@ -1344,7 +1397,7 @@ WHERE addr=?
let fingerprint_other = fingerprint_other.to_string();
let stock_message = if contact.public_key(context).await?.is_some() {
stock_str::messages_e2e_encrypted(context).await
stock_str::messages_are_e2ee(context).await
} else {
stock_str::encr_none(context).await
};
@@ -1384,6 +1437,16 @@ WHERE addr=?
);
}
if let Some(public_key) = contact.public_key(context).await?
&& let Some(relay_addrs) = addresses_from_public_key(&public_key)
{
ret += "\n\nRelays:";
for relay in &relay_addrs {
ret += "\n";
ret += relay;
}
}
Ok(ret)
}

View File

@@ -841,7 +841,10 @@ Me (alice@example.org):
bob@example.net (bob@example.net):
CCCB 5AA9 F6E1 141C 9431
65F1 DB18 B18C BCF7 0487"
65F1 DB18 B18C BCF7 0487
Relays:
bob@example.net"
);
let contact = Contact::get_by_id(alice, contact_bob_id).await?;
assert!(contact.e2ee_avail(alice).await?);

View File

@@ -10,6 +10,7 @@ use std::time::Duration;
use anyhow::{Result, bail, ensure};
use async_channel::{self as channel, Receiver, Sender};
use pgp::composed::SignedPublicKey;
use ratelimit::Ratelimit;
use tokio::sync::{Mutex, Notify, RwLock};
@@ -233,8 +234,6 @@ pub struct InnerContext {
/// This is a global mutex-like state for operations which should be modal in the
/// clients.
running_state: RwLock<RunningState>,
/// Mutex to avoid generating the key for the user more than once.
pub(crate) generating_key_mutex: Mutex<()>,
/// Mutex to enforce only a single running oauth2 is running.
pub(crate) oauth2_mutex: Mutex<()>,
/// Mutex to prevent a race condition when a "your pw is wrong" warning is sent, resulting in multiple messages being sent.
@@ -242,6 +241,14 @@ pub struct InnerContext {
/// Mutex to prevent running housekeeping from multiple threads at once.
pub(crate) housekeeping_mutex: Mutex<()>,
/// Mutex to prevent multiple IMAP loops from fetching the messages at once.
///
/// Without this mutex IMAP loops may waste traffic downloading the same message
/// from multiple IMAP servers and create multiple copies of the same message
/// in the database if the check for duplicates and creating a message
/// happens in separate database transactions.
pub(crate) fetch_msgs_mutex: Mutex<()>,
pub(crate) translated_stockstrings: StockStrings,
pub(crate) events: Events,
@@ -309,6 +316,13 @@ pub struct InnerContext {
/// the standard library's OnceLock is enough, and it's a lot smaller in memory.
pub(crate) self_fingerprint: OnceLock<String>,
/// OpenPGP certificate aka Transferrable Public Key.
///
/// It is generated on first use from the secret key stored in the database.
///
/// Mutex is also held while generating the key to avoid generating the key twice.
pub(crate) self_public_key: Mutex<Option<SignedPublicKey>>,
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
@@ -478,10 +492,10 @@ impl Context {
running_state: RwLock::new(Default::default()),
sql: Sql::new(dbfile),
smeared_timestamp: SmearedTimestamp::new(),
generating_key_mutex: Mutex::new(()),
oauth2_mutex: Mutex::new(()),
wrong_pw_warning_mutex: Mutex::new(()),
housekeeping_mutex: Mutex::new(()),
fetch_msgs_mutex: Mutex::new(()),
translated_stockstrings: stockstrings,
events,
scheduler: SchedulerState::new(),
@@ -499,6 +513,7 @@ impl Context {
tls_session_store: TlsSessionStore::new(),
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
self_public_key: Mutex::new(None),
connectivities: parking_lot::Mutex::new(Vec::new()),
pre_encrypt_mime_hook: None.into(),
};

View File

@@ -4,21 +4,249 @@
use std::collections::HashSet;
use std::io::Cursor;
use ::pgp::composed::Message;
use anyhow::Result;
use anyhow::{Context as _, Result, bail};
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::pgp;
use crate::chat::ChatId;
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::context::Context;
use crate::key::self_fingerprint;
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 self_fp = self_fingerprint(context).await?;
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, self_fp)?;
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();
let alice_fp = invite.fingerprint().hex();
let shared_secret = format!("securejoin/{alice_fp}/{authcode}");
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
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,
self_fingerprint: &str,
) -> 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)?;
let shared_secret = format!("securejoin/{self_fingerprint}/{token}");
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
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 {
return Ok(None);
};
let data = encrypted_data_part.get_body_raw()?;
let cursor = Cursor::new(data);
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.
@@ -125,8 +353,10 @@ pub(crate) fn validate_detached_signature<'a, 'b>(
// First part is the content, second part is the signature.
let content = first_part.raw_bytes;
let ret_valid_signatures = match second_part.get_body_raw() {
Ok(signature) => pgp::pk_validate(content, &signature, public_keyring_for_validate)
.unwrap_or_default(),
Ok(signature) => {
crate::pgp::pk_validate(content, &signature, public_keyring_for_validate)
.unwrap_or_default()
}
Err(_) => Default::default(),
};
Some((first_part, ret_valid_signatures))

View File

@@ -397,6 +397,8 @@ pub enum EventType {
msg_id: MsgId,
/// ID of the chat which the message belongs to.
chat_id: ChatId,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.

View File

@@ -606,6 +606,7 @@ impl Imap {
.await
.context("prefetch")?;
let read_cnt = msgs.len();
let _fetch_msgs_lock_guard = context.fetch_msgs_mutex.lock().await;
let mut uids_fetch: Vec<u32> = Vec::new();
let mut available_post_msgs: Vec<String> = Vec::new();

View File

@@ -7,10 +7,18 @@ use std::io::Cursor;
use anyhow::{Context as _, Result, bail, ensure};
use base64::Engine as _;
use deltachat_contact_tools::EmailAddress;
use pgp::composed::Deserializable;
use pgp::composed::{Deserializable, SignedKeyDetails};
pub use pgp::composed::{SignedPublicKey, SignedSecretKey};
use pgp::crypto::aead::AeadAlgorithm;
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{
Features, KeyFlags, Notation, PacketTrait as _, SignatureConfig, SignatureType, Subpacket,
SubpacketData,
};
use pgp::ser::Serialize;
use pgp::types::KeyDetails;
use pgp::types::{CompressionAlgorithm, KeyDetails, KeyVersion};
use rand_old::thread_rng;
use tokio::runtime::Handle;
use crate::context::Context;
@@ -114,10 +122,149 @@ pub trait DcKey: Serialize + Deserializable + Clone {
fn is_private() -> bool;
}
/// Converts secret key to public key.
pub(crate) fn secret_key_to_public_key(
context: &Context,
mut signed_secret_key: SignedSecretKey,
timestamp: u32,
addr: &str,
relay_addrs: &str,
) -> Result<SignedPublicKey> {
info!(context, "Converting secret key to public key.");
let timestamp = pgp::types::Timestamp::from_secs(timestamp);
// Subpackets that we want to share between DKS and User ID signature.
let common_subpackets = || -> Result<Vec<Subpacket>> {
let keyflags = {
let mut keyflags = KeyFlags::default();
keyflags.set_certify(true);
keyflags.set_sign(true);
keyflags
};
let features = {
let mut features = Features::default();
features.set_seipd_v1(true);
features.set_seipd_v2(true);
features
};
Ok(vec![
Subpacket::regular(SubpacketData::SignatureCreationTime(timestamp))?,
Subpacket::regular(SubpacketData::IssuerFingerprint(
signed_secret_key.fingerprint(),
))?,
Subpacket::regular(SubpacketData::KeyFlags(keyflags))?,
Subpacket::regular(SubpacketData::Features(features))?,
Subpacket::regular(SubpacketData::PreferredSymmetricAlgorithms(smallvec![
SymmetricKeyAlgorithm::AES256,
SymmetricKeyAlgorithm::AES192,
SymmetricKeyAlgorithm::AES128
]))?,
Subpacket::regular(SubpacketData::PreferredHashAlgorithms(smallvec![
HashAlgorithm::Sha256,
HashAlgorithm::Sha384,
HashAlgorithm::Sha512,
HashAlgorithm::Sha224,
]))?,
Subpacket::regular(SubpacketData::PreferredCompressionAlgorithms(smallvec![
CompressionAlgorithm::ZLIB,
CompressionAlgorithm::ZIP,
]))?,
Subpacket::regular(SubpacketData::PreferredAeadAlgorithms(smallvec![(
SymmetricKeyAlgorithm::AES256,
AeadAlgorithm::Ocb
)]))?,
Subpacket::regular(SubpacketData::IsPrimary(true))?,
])
};
// RFC 4880 required that Transferrable Public Key (aka OpenPGP Certificate)
// contains at least one User ID:
// <https://www.rfc-editor.org/rfc/rfc4880#section-11.1>
// RFC 9580 does not require User ID even for V4 certificates anymore:
// <https://www.rfc-editor.org/rfc/rfc9580.html#name-openpgp-version-4-certifica>
//
// We do not use and do not expect User ID in any keys,
// but nevertheless include User ID in V4 keys for compatibility with clients that follow RFC 4880.
// RFC 9580 also recommends including User ID into V4 keys:
// <https://www.rfc-editor.org/rfc/rfc9580.html#section-5.2.3.10-8>
//
// We do not support keys older than V4 and are not going
// to include User ID in newer V6 keys as all clients that support V6
// should support keys without User ID.
let users = if signed_secret_key.version() == KeyVersion::V4 {
let user_id = format!("<{addr}>");
let mut rng = thread_rng();
// Self-signature is a "positive certification",
// see <https://www.ietf.org/archive/id/draft-gallagher-openpgp-signatures-02.html#name-certification-signature-typ>.
let mut user_id_signature_config = SignatureConfig::from_key(
&mut rng,
&signed_secret_key.primary_key,
SignatureType::CertPositive,
)?;
user_id_signature_config.hashed_subpackets = common_subpackets()?;
user_id_signature_config.unhashed_subpackets = vec![Subpacket::regular(
SubpacketData::IssuerKeyId(signed_secret_key.legacy_key_id()),
)?];
let user_id_packet =
pgp::packet::UserId::from_str(pgp::types::PacketHeaderVersion::New, &user_id)?;
let signature = user_id_signature_config.sign_certification(
&signed_secret_key.primary_key,
&signed_secret_key.primary_key.public_key(),
&pgp::types::Password::empty(),
user_id_packet.tag(),
&user_id_packet,
)?;
vec![user_id_packet.into_signed(signature)]
} else {
vec![]
};
let direct_signatures = {
let mut rng = thread_rng();
let mut direct_key_signature_config = SignatureConfig::from_key(
&mut rng,
&signed_secret_key.primary_key,
SignatureType::Key,
)?;
direct_key_signature_config.hashed_subpackets = common_subpackets()?;
let notation = Notation {
readable: true,
name: "relays@chatmail.at".into(),
value: relay_addrs.to_string().into(),
};
direct_key_signature_config
.hashed_subpackets
.push(Subpacket::regular(SubpacketData::Notation(notation))?);
let direct_key_signature = direct_key_signature_config.sign_key(
&signed_secret_key.primary_key,
&pgp::types::Password::empty(),
signed_secret_key.primary_key.public_key(),
)?;
vec![direct_key_signature]
};
signed_secret_key.details = SignedKeyDetails {
revocation_signatures: vec![],
direct_signatures,
users,
user_attributes: vec![],
};
Ok(signed_secret_key.to_public_key())
}
/// Attempts to load own public key.
///
/// Returns `None` if no key is generated yet.
/// Returns `None` if no secret key is generated yet.
pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option<SignedPublicKey>> {
let mut lock = context.self_public_key.lock().await;
if let Some(ref public_key) = *lock {
return Ok(Some(public_key.clone()));
}
let Some(secret_key_bytes) = context
.sql
.query_row_optional(
@@ -135,7 +282,25 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
return Ok(None);
};
let signed_secret_key = SignedSecretKey::from_slice(&secret_key_bytes)?;
let signed_public_key = signed_secret_key.to_public_key();
let timestamp = context
.sql
.query_get_value::<u32>(
"SELECT MAX(timestamp)
FROM (SELECT add_timestamp AS timestamp
FROM transports
UNION ALL
SELECT remove_timestamp AS timestamp
FROM removed_transports)",
(),
)
.await?
.context("No transports configured")?;
let addr = context.get_primary_self_addr().await?;
let all_addrs = context.get_published_self_addrs().await?.join(",");
let signed_public_key =
secret_key_to_public_key(context, signed_secret_key, timestamp, &addr, &all_addrs)?;
*lock = Some(signed_public_key.clone());
Ok(Some(signed_public_key))
}
@@ -146,8 +311,11 @@ pub(crate) async fn load_self_public_key(context: &Context) -> Result<SignedPubl
match load_self_public_key_opt(context).await? {
Some(public_key) => Ok(public_key),
None => {
let signed_secret_key = generate_keypair(context).await?;
Ok(signed_secret_key.to_public_key())
generate_keypair(context).await?;
let public_key = load_self_public_key_opt(context)
.await?
.context("Secret key generated, but public key cannot be created")?;
Ok(public_key)
}
}
}
@@ -287,7 +455,7 @@ impl DcKey for SignedSecretKey {
async fn generate_keypair(context: &Context) -> Result<SignedSecretKey> {
let addr = context.get_primary_self_addr().await?;
let addr = EmailAddress::new(&addr)?;
let _guard = context.generating_key_mutex.lock().await;
let _public_key_guard = context.self_public_key.lock().await;
// Check if the key appeared while we were waiting on the lock.
match load_keypair(context).await? {
@@ -343,6 +511,12 @@ pub(crate) async fn store_self_keypair(
context: &Context,
signed_secret_key: &SignedSecretKey,
) -> Result<()> {
// This public key is stored in the database
// only for backwards compatibility.
//
// It should not be used e.g. in Autocrypt headers or vCards.
// Use `secret_key_to_public_key()` function instead,
// which adds relay list to the signature.
let signed_public_key = signed_secret_key.to_public_key();
let mut config_cache_lock = context.sql.config_cache.write().await;
let new_key_id = context

View File

@@ -79,6 +79,16 @@ pub struct EnteredServerLoginParam {
pub password: String,
}
/// A transport, as shown in the "relays" list in the UI.
#[derive(Debug)]
pub struct Transport {
/// The login data entered by the user.
pub param: EnteredLoginParam,
/// Whether this transport is set to 'unpublished'.
/// See [`Context::set_transport_unpublished`] for details.
pub is_unpublished: bool,
}
/// Login parameters entered by the user.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnteredLoginParam {

View File

@@ -33,7 +33,7 @@ use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{SystemMessage, is_hidden};
use crate::param::Param;
use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
use crate::pgp::SeipdVersion;
use crate::pgp::{SeipdVersion, addresses_from_public_key};
use crate::simplify::escape_message_footer_marks;
use crate::stock_str;
use crate::tools::{
@@ -274,10 +274,13 @@ impl MimeFactory {
.await?
.context("Can't send member addition/removal: missing key")?;
recipients.push(addr.clone());
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
let relays =
addresses_from_public_key(&public_key).unwrap_or_else(|| vec![addr.clone()]);
recipients.extend(relays);
to.push((authname, addr.clone()));
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
encryption_pubkeys = Some(vec![(addr, public_key)]);
} else {
let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
@@ -354,9 +357,23 @@ impl MimeFactory {
false => "".to_string(),
};
if add_timestamp >= remove_timestamp {
let relays = if let Some(public_key) = public_key_opt {
let addrs = addresses_from_public_key(&public_key);
keys.push((addr.clone(), public_key));
addrs
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
missing_key_addresses.insert(addr.clone());
if is_encrypted {
warn!(context, "Missing key for {addr}");
}
None
} else {
None
}.unwrap_or_else(|| vec![addr.clone()]);
if !recipients_contain_addr(&to, &addr) {
if id != ContactId::SELF {
recipients.push(addr.clone());
recipients.extend(relays);
}
if !undisclosed_recipients {
to.push((name, addr.clone()));
@@ -367,42 +384,38 @@ impl MimeFactory {
} else if id == ContactId::SELF {
member_fingerprints.push(self_fingerprint.to_string());
} else {
ensure_and_debug_assert!(member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too");
ensure_and_debug_assert!(member_fingerprints.is_empty(), "If some member is a key-contact, all other members should be key-contacts too");
}
}
member_timestamps.push(add_timestamp);
}
}
recipient_ids.insert(id);
if let Some(public_key) = public_key_opt {
keys.push((addr.clone(), public_key))
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
missing_key_addresses.insert(addr.clone());
if is_encrypted {
warn!(context, "Missing key for {addr}");
}
}
} else if remove_timestamp.saturating_add(60 * 24 * 3600) > now {
// Row is a tombstone,
// member is not actually part of the group.
if !recipients_contain_addr(&past_members, &addr) {
if let Some(email_to_remove) = email_to_remove
&& email_to_remove == addr {
// This is a "member removed" message,
// we need to notify removed member
// that it was removed.
if id != ContactId::SELF {
recipients.push(addr.clone());
}
if let Some(public_key) = public_key_opt {
keys.push((addr.clone(), public_key))
let relays = if let Some(public_key) = public_key_opt {
let addrs = addresses_from_public_key(&public_key);
keys.push((addr.clone(), public_key));
addrs
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
missing_key_addresses.insert(addr.clone());
if is_encrypted {
warn!(context, "Missing key for {addr}");
}
None
} else {
None
}.unwrap_or_else(|| vec![addr.clone()]);
// This is a "member removed" message,
// we need to notify removed member
// that it was removed.
if id != ContactId::SELF {
recipients.extend(relays);
}
}
if !undisclosed_recipients {
@@ -1466,7 +1479,7 @@ impl MimeFactory {
}
}
SystemMessage::GroupNameChanged => {
placeholdertext = Some("Group name changed.".to_string());
placeholdertext = Some("Chat name changed.".to_string());
let old_name = msg.param.get(Param::Arg).unwrap_or_default().to_string();
headers.push((
"Chat-Group-Name-Changed",
@@ -1483,7 +1496,7 @@ impl MimeFactory {
));
}
SystemMessage::GroupImageChanged => {
placeholdertext = Some("Group image changed.".to_string());
placeholdertext = Some("Chat image changed.".to_string());
headers.push((
"Chat-Content",
mail_builder::headers::text::Text::new("group-avatar-changed").into(),
@@ -1958,7 +1971,7 @@ impl MimeFactory {
}
/// 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>)>,
outer_message: MimePart<'static>,
) -> String {
@@ -1976,7 +1989,7 @@ fn render_outer_message(
/// Takes the encrypted part, wraps it in a MimePart,
/// and sets the appropriate Content-Type for the outer message
fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> {
pub(crate) fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> {
// XXX: additional newline is needed
// to pass filtermail at
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
@@ -2322,6 +2335,7 @@ pub(crate) async fn render_symm_encrypted_securejoin_message(
rfc724_mid: &str,
attach_self_pubkey: bool,
auth: &str,
shared_secret: &str,
) -> Result<String> {
info!(context, "Sending secure-join message {step:?}.");
@@ -2414,7 +2428,7 @@ pub(crate) async fn render_symm_encrypted_securejoin_message(
// Only sign the message if we attach the pubkey.
let sign = attach_self_pubkey;
let encrypted = encrypt_helper
.encrypt_symmetrically(context, auth, message, compress, sign)
.encrypt_symmetrically(context, shared_secret, message, compress, sign)
.await?;
wrap_encrypted_part(encrypted)

View File

@@ -6,7 +6,7 @@ use std::path::Path;
use std::str;
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_derive::{FromSql, ToSql};
use format_flowed::unformat_flowed;
@@ -18,14 +18,15 @@ use crate::authres::handle_authres;
use crate::blob::BlobObject;
use crate::chat::ChatId;
use crate::config::Config;
use crate::contact::ContactId;
use crate::constants;
use crate::contact::{ContactId, import_public_key};
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::download::PostMsgMetadata;
use crate::events::EventType;
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::message::{self, Message, MsgId, Viewtype, get_vcard_summary, set_msg_failed};
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,
};
use crate::{chatlist_events, location, tools};
use crate::{constants, token};
/// Public key extracted from `Autocrypt-Gossip`
/// header with associated information.
@@ -363,7 +363,6 @@ impl MimeMessage {
Self::remove_secured_headers(&mut headers, &mut headers_removed, encrypted);
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?;
@@ -385,24 +384,12 @@ impl MimeMessage {
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 decrypted_msg; // Decrypted signed OpenPGP message.
let expected_sender_fingerprint: Option<String>;
let (mail, is_encrypted) = match tokio::task::block_in_place(|| {
encrypted_pgp_message.map(|e| crate::pgp::decrypt(e, &private_keyring, &secrets))
}) {
Some(Ok(mut msg)) => {
let (mail, is_encrypted) = match decrypt::decrypt(context, &mail).await {
Ok(Some((mut msg, expected_sender_fp))) => {
mail_raw = msg.as_data_vec().unwrap_or_default();
let decrypted_mail = mailparse::parse_mail(&mail_raw)?;
@@ -429,16 +416,19 @@ impl MimeMessage {
aheader_values = protected_aheader_values;
}
expected_sender_fingerprint = expected_sender_fp;
(Ok(decrypted_mail), true)
}
None => {
Ok(None) => {
mail_raw = Vec::new();
decrypted_msg = None;
expected_sender_fingerprint = None;
(Ok(mail), false)
}
Some(Err(err)) => {
Err(err) => {
mail_raw = Vec::new();
decrypted_msg = None;
expected_sender_fingerprint = None;
warn!(context, "decryption failed: {:#}", err);
(Err(err), false)
}
@@ -468,22 +458,9 @@ impl MimeMessage {
let autocrypt_fingerprint = if let Some(autocrypt_header) = &autocrypt_header {
let fingerprint = autocrypt_header.public_key.dc_fingerprint().hex();
let inserted = context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO NOTHING",
(&fingerprint, autocrypt_header.public_key.to_bytes()),
)
.await?;
if inserted > 0 {
info!(
context,
"Saved key with fingerprint {fingerprint} from the Autocrypt header"
);
}
import_public_key(context, &autocrypt_header.public_key)
.await
.context("Failed to import public key from the Autocrypt header")?;
Some(fingerprint)
} else {
None
@@ -552,6 +529,22 @@ impl MimeMessage {
signatures.extend(signatures_detached);
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 !signatures.is_empty() {
// Unsigned "Subject" mustn't be prepended to messages shown as encrypted
@@ -1653,24 +1646,12 @@ impl MimeMessage {
}
Ok(key) => key,
};
if let Err(err) = key.verify_bindings() {
warn!(context, "Attached PGP key verification failed: {err:#}.");
if let Err(err) = import_public_key(context, &key).await {
warn!(context, "Attached PGP key import failed: {err:#}.");
return Ok(false);
}
let fingerprint = key.dc_fingerprint().hex();
context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO NOTHING",
(&fingerprint, key.to_bytes()),
)
.await?;
info!(context, "Imported PGP key {fingerprint} from attachment.");
info!(context, "Imported PGP key from attachment.");
Ok(true)
}
@@ -2110,35 +2091,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 {
let mut res = None;
for l in text.lines() {
@@ -2208,17 +2160,9 @@ async fn parse_gossip_headers(
continue;
}
let fingerprint = header.public_key.dc_fingerprint().hex();
context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO NOTHING",
(&fingerprint, header.public_key.to_bytes()),
)
.await?;
import_public_key(context, &header.public_key)
.await
.context("Failed to import Autocrypt-Gossip key")?;
let gossiped_key = GossipedKey {
public_key: header.public_key,
@@ -2656,3 +2600,5 @@ async fn handle_ndn(
#[cfg(test)]
mod mimeparser_tests;
#[cfg(test)]
mod shared_secret_decryption_tests;

View File

@@ -2171,9 +2171,6 @@ async fn test_load_shared_secrets_with_legacy_state() -> Result<()> {
()
).await?;
// This call must not fail:
load_shared_secrets(alice).await.unwrap();
let qr: QrInvite = alice
.sql
.query_get_value("SELECT invite FROM bobstate", ())

View File

@@ -0,0 +1,258 @@
use super::*;
use crate::chat::{create_broadcast, load_broadcast_secret};
use crate::constants::DC_CHAT_ID_TRASH;
use crate::key::{load_self_secret_key, self_fingerprint};
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_for_encryption: &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_for_encryption,
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(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?;
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
let alice_fp = self_fingerprint(alice).await?;
let secret_for_encryption = format!("securejoin/{alice_fp}/{authcode}");
test_shared_secret_decryption_ex(
bob,
&charlie_addr,
&secret_for_encryption,
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?;
let alice_fp = self_fingerprint(alice).await?;
let secret_for_encryption = format!("securejoin/{alice_fp}/{authcode}");
test_shared_secret_decryption_ex(
bob,
"alice@example.net",
&secret_for_encryption,
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
}

View File

@@ -3,23 +3,25 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{BufRead, Cursor};
use anyhow::{Context as _, Result, bail};
use deltachat_contact_tools::EmailAddress;
use anyhow::{Context as _, Result, ensure};
use deltachat_contact_tools::{EmailAddress, may_be_valid_addr};
use pgp::armor::BlockType;
use pgp::composed::{
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, EncryptionCaps,
KeyType as PgpKeyType, Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey,
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig, TheRing,
ArmorOptions, Deserializable, DetachedSignature, EncryptionCaps, KeyType as PgpKeyType,
Message, MessageBuilder, SecretKeyParamsBuilder, SignedKeyDetails, SignedPublicKey,
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig,
};
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
use pgp::crypto::ecc_curve::ECCCurve;
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
use pgp::packet::{Signature, SignatureConfig, SignatureType, Subpacket, SubpacketData};
use pgp::types::{
CompressionAlgorithm, KeyDetails, KeyVersion, Password, SigningKey as _, StringToKey,
CompressionAlgorithm, Imprint, KeyDetails, KeyVersion, Password, SignedUser, SigningKey as _,
StringToKey,
};
use rand_old::{Rng as _, thread_rng};
use sha2::Sha256;
use tokio::runtime::Handle;
use crate::key::{DcKey, Fingerprint};
@@ -293,94 +295,6 @@ pub fn pk_calc_signature(
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
/// of all keys from the `public_keys_for_validation` keyring that
/// have valid signatures in `msg` and corresponding intended recipient fingerprints
@@ -508,6 +422,166 @@ pub async fn symm_decrypt<T: BufRead + std::fmt::Debug + 'static + Send>(
.await?
}
/// Merges and minimizes OpenPGP certificates.
///
/// Keeps at most one direct key signature and
/// at most one User ID with exactly one signature.
///
/// See <https://openpgp.dev/book/adv/certificates.html#merging>
/// and <https://openpgp.dev/book/adv/certificates.html#certificate-minimization>.
///
/// `new_certificate` does not necessarily contain newer data.
/// It may come not directly from the key owner,
/// e.g. via protected Autocrypt header or protected attachment
/// in a signed message, but from Autocrypt-Gossip header or a vCard.
/// Gossiped key may be older than the one we have
/// or even have some packets maliciously dropped
/// (for example, all encryption subkeys dropped)
/// or restored from some older version of the certificate.
pub fn merge_openpgp_certificates(
old_certificate: SignedPublicKey,
new_certificate: SignedPublicKey,
) -> Result<SignedPublicKey> {
old_certificate
.verify_bindings()
.context("First key cannot be verified")?;
new_certificate
.verify_bindings()
.context("Second key cannot be verified")?;
// Decompose certificates.
let SignedPublicKey {
primary_key: old_primary_key,
details: old_details,
public_subkeys: old_public_subkeys,
} = old_certificate;
let SignedPublicKey {
primary_key: new_primary_key,
details: new_details,
public_subkeys: _new_public_subkeys,
} = new_certificate;
// Public keys may be serialized differently, e.g. using old and new packet type,
// so we compare imprints instead of comparing the keys
// directly with `old_primary_key == new_primary_key`.
// Imprints, like fingerprints, are calculated over normalized packets.
// On error we print fingerprints as this is what is used in the database
// and what most tools show.
let old_imprint = old_primary_key.imprint::<Sha256>()?;
let new_imprint = new_primary_key.imprint::<Sha256>()?;
ensure!(
old_imprint == new_imprint,
"Cannot merge certificates with different primary keys {} and {}",
old_primary_key.fingerprint(),
new_primary_key.fingerprint()
);
// Decompose old and the new key details.
//
// Revocation signatures are currently ignored so we do not store them.
//
// User attributes are thrown away on purpose,
// the only defined in RFC 9580 attribute is the Image Attribute
// (<https://www.rfc-editor.org/rfc/rfc9580.html#section-5.12.1>
// which we do not use and do not want to gossip.
let SignedKeyDetails {
revocation_signatures: _old_revocation_signatures,
direct_signatures: old_direct_signatures,
users: old_users,
user_attributes: _old_user_attributes,
} = old_details;
let SignedKeyDetails {
revocation_signatures: _new_revocation_signatures,
direct_signatures: new_direct_signatures,
users: new_users,
user_attributes: _new_user_attributes,
} = new_details;
// Select at most one direct key signature, the newest one.
let best_direct_key_signature: Option<Signature> = old_direct_signatures
.into_iter()
.chain(new_direct_signatures)
.filter(|x: &Signature| x.verify_key(&old_primary_key).is_ok())
.max_by_key(|x: &Signature|
// Converting to seconds because `Ord` is not derived for `Timestamp`:
// <https://github.com/rpgp/rpgp/issues/737>
x.created().map_or(0, |ts| ts.as_secs()));
let direct_signatures: Vec<Signature> = best_direct_key_signature.into_iter().collect();
// Select at most one User ID.
//
// We prefer User IDs marked as primary,
// but will select non-primary otherwise
// because sometimes keys have no primary User ID,
// such as Alice's key in `test-data/key/alice-secret.asc`.
let best_user: Option<SignedUser> = old_users
.into_iter()
.chain(new_users.clone())
.filter_map(|SignedUser { id, signatures }| {
// Select the best signature for each User ID.
// If User ID has no valid signatures, it is filtered out.
let best_user_signature: Option<Signature> = signatures
.into_iter()
.filter(|signature: &Signature| {
signature
.verify_certification(&old_primary_key, pgp::types::Tag::UserId, &id)
.is_ok()
})
.max_by_key(|signature: &Signature| {
signature.created().map_or(0, |ts| ts.as_secs())
});
best_user_signature.map(|signature| (id, signature))
})
.max_by_key(|(_id, signature)| signature.created().map_or(0, |ts| ts.as_secs()))
.map(|(id, signature)| SignedUser {
id,
signatures: vec![signature],
});
let users: Vec<SignedUser> = best_user.into_iter().collect();
let public_subkeys = old_public_subkeys;
Ok(SignedPublicKey {
primary_key: old_primary_key,
details: SignedKeyDetails {
revocation_signatures: vec![],
direct_signatures,
users,
user_attributes: vec![],
},
public_subkeys,
})
}
/// Returns relays addresses from the public key signature.
///
/// Not more than 3 relays are returned for each key.
pub(crate) fn addresses_from_public_key(public_key: &SignedPublicKey) -> Option<Vec<String>> {
for signature in &public_key.details.direct_signatures {
// The signature should be verified already when importing the key,
// but we double-check here.
let signature_is_valid = signature.verify_key(&public_key.primary_key).is_ok();
debug_assert!(signature_is_valid);
if signature_is_valid {
for notation in signature.notations() {
if notation.name == "relays@chatmail.at"
&& let Ok(value) = str::from_utf8(&notation.value)
{
return Some(
value
.split(",")
.map(|s| s.to_string())
.filter(|s| may_be_valid_addr(s))
.take(3)
.collect(),
);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use std::sync::LazyLock;
@@ -515,24 +589,42 @@ mod tests {
use super::*;
use crate::{
key::{load_self_public_key, load_self_secret_key},
test_utils::{TestContextManager, alice_keypair, bob_keypair},
config::Config,
decrypt,
key::{load_self_public_key, self_fingerprint, 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::packet::PublicKeyEncryptedSessionKey;
fn decrypt_bytes(
async fn decrypt_bytes(
bytes: Vec<u8>,
private_keys_for_decryption: &[SignedSecretKey],
shared_secrets: &[String],
auth_tokens_for_decryption: &[String],
) -> Result<pgp::composed::Message<'static>> {
let cursor = Cursor::new(bytes);
let (msg, _headers) = Message::from_armor(cursor).unwrap();
decrypt(msg, private_keys_for_decryption, shared_secrets)
let t = &TestContext::new().await;
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
.await
.expect("Failed to configure address");
for secret in auth_tokens_for_decryption {
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)]
fn pk_decrypt_and_validate<'a>(
async fn pk_decrypt_and_validate<'a>(
ctext: &'a [u8],
private_keys_for_decryption: &'a [SignedSecretKey],
public_keys_for_validation: &[SignedPublicKey],
@@ -541,7 +633,7 @@ mod tests {
HashMap<Fingerprint, Vec<Fingerprint>>,
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 ret_signature_fingerprints =
valid_signature_fingerprints(&msg, public_keys_for_validation);
@@ -655,6 +747,7 @@ mod tests {
&decrypt_keyring,
&sig_check_keyring,
)
.await
.unwrap();
assert_eq!(content, CLEARTEXT);
assert_eq!(valid_signatures.len(), 1);
@@ -670,6 +763,7 @@ mod tests {
&decrypt_keyring,
&sig_check_keyring,
)
.await
.unwrap();
assert_eq!(content, CLEARTEXT);
assert_eq!(valid_signatures.len(), 1);
@@ -682,7 +776,9 @@ mod tests {
async fn test_decrypt_no_sig_check() {
let keyring = vec![KEYS.alice_secret.clone()];
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!(valid_signatures.len(), 0);
}
@@ -697,6 +793,7 @@ mod tests {
&decrypt_keyring,
&sig_check_keyring,
)
.await
.unwrap();
assert_eq!(content, CLEARTEXT);
assert_eq!(valid_signatures.len(), 0);
@@ -707,57 +804,64 @@ mod tests {
let decrypt_keyring = vec![KEYS.bob_secret.clone()];
let ctext_unsigned = include_bytes!("../test-data/message/ctext_unsigned.asc");
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!(valid_signatures.len(), 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_encrypt_decrypt_broadcast() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
async fn test_dont_decrypt_expensive_message_happy_path() -> Result<()> {
let s2k = StringToKey::Salted {
hash_alg: HashAlgorithm::default(),
salt: [1; 8],
};
let plain = Vec::from(b"this is the secret message");
let shared_secret = "shared secret";
let ctext = symm_encrypt_message(
plain.clone(),
Some(load_self_secret_key(alice).await?),
shared_secret,
true,
)
.await?;
test_dont_decrypt_expensive_message_ex(s2k, false, None).await
}
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
let mut decrypted = decrypt_bytes(
ctext.into(),
&bob_private_keyring,
&[shared_secret.to_string()],
)?;
#[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
assert_eq!(decrypted.as_data_vec()?, plain);
test_dont_decrypt_expensive_message_ex(s2k, false, Some("unsupported string2key algorithm"))
.await
}
Ok(())
#[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
/// that is symmetrically encrypted
/// with an expensive string2key algorithm
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dont_decrypt_expensive_message() -> Result<()> {
/// or multiple shared secrets.
/// 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 bob = &tcm.bob().await;
let plain = Vec::from(b"this is the secret message");
let shared_secret = "shared secret";
let bob_fp = self_fingerprint(bob).await?;
// 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(format!("securejoin/{bob_fp}/{shared_secret}"));
let msg = MessageBuilder::from_bytes("", plain);
let mut rng = thread_rng();
let s2k = StringToKey::new_default(&mut rng); // Default is IteratedAndSalted
let mut msg = msg.seipd_v2(
&mut rng,
@@ -765,24 +869,28 @@ mod tests {
AeadAlgorithm::Ocb,
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())?;
// Trying to decrypt it should fail with a helpful error message:
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
let error = decrypt_bytes(
let res = decrypt_bytes(
ctext.into(),
&bob_private_keyring,
&[shared_secret.to_string()],
)
.unwrap_err();
.await;
assert_eq!(
error.to_string(),
"missing key (Note: symmetric decryption was not tried: unsupported string2key algorithm)"
);
if let Some(expected_error_msg) = expected_error_msg {
assert_eq!(format!("{:#}", res.unwrap_err()), expected_error_msg);
} else {
res.unwrap();
}
Ok(())
}
@@ -809,12 +917,11 @@ mod tests {
// Trying to decrypt it should fail with an OK error message:
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!(
error.to_string(),
"missing key (Note: symmetric decryption was not tried: not symmetrically encrypted)"
);
assert_eq!(format!("{error:#}"), "decrypt_with_keys: missing key");
Ok(())
}
@@ -848,4 +955,24 @@ mod tests {
}
Ok(())
}
#[test]
fn test_merge_openpgp_certificates() {
let alice = alice_keypair().to_public_key();
let bob = bob_keypair().to_public_key();
// Merging certificate with itself does not change it.
assert_eq!(
merge_openpgp_certificates(alice.clone(), alice.clone()).unwrap(),
alice
);
assert_eq!(
merge_openpgp_certificates(bob.clone(), bob.clone()).unwrap(),
bob
);
// Cannot merge certificates with different primary key.
assert!(merge_openpgp_certificates(alice.clone(), bob.clone()).is_err());
assert!(merge_openpgp_certificates(bob.clone(), alice.clone()).is_err());
}
}

View File

@@ -861,6 +861,10 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
if transport_changed {
info!(context, "Primary transport changed to {from_addr:?}.");
context.sql.uncache_raw_config("configured_addr").await;
// Regenerate User ID in V4 keys.
context.self_public_key.lock().await.take();
context.emit_event(EventType::TransportsModified);
}
} else {
@@ -3332,8 +3336,13 @@ async fn apply_chat_name_avatar_and_description_changes(
.is_some()
{
let old_name = &sanitize_single_line(old_name);
better_msg
.get_or_insert(stock_str::msg_grp_name(context, old_name, grpname, from_id).await);
better_msg.get_or_insert(
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
stock_str::msg_broadcast_name_changed(context, old_name, grpname).await
} else {
stock_str::msg_grp_name(context, old_name, grpname, from_id).await
},
);
}
}
@@ -3390,10 +3399,18 @@ async fn apply_chat_name_avatar_and_description_changes(
{
// this is just an explicit message containing the group-avatar,
// apart from that, the group-avatar is send along with various other messages
better_msg.get_or_insert(match avatar_action {
AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await,
AvatarAction::Change(_) => stock_str::msg_grp_img_changed(context, from_id).await,
});
better_msg.get_or_insert(
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
stock_str::msg_broadcast_img_changed(context).await
} else {
match avatar_action {
AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await,
AvatarAction::Change(_) => {
stock_str::msg_grp_img_changed(context, from_id).await
}
}
},
);
}
if let Some(avatar_action) = &mime_parser.group_avatar {

View File

@@ -17,7 +17,7 @@ use crate::context::Context;
use crate::e2ee::ensure_secret_key_exists;
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::key::{DcKey, Fingerprint, load_self_public_key};
use crate::key::{DcKey, Fingerprint, load_self_public_key, self_fingerprint};
use crate::log::LogExt as _;
use crate::log::warn;
use crate::message::{self, Message, MsgId, Viewtype};
@@ -540,12 +540,15 @@ pub(crate) async fn handle_securejoin_handshake(
let rfc724_mid = create_outgoing_rfc724_mid();
let addr = ContactAddress::new(&mime_message.from.addr)?;
let attach_self_pubkey = true;
let self_fp = self_fingerprint(context).await?;
let shared_secret = format!("securejoin/{self_fp}/{auth}");
let rendered_message = mimefactory::render_symm_encrypted_securejoin_message(
context,
"vc-pubkey",
&rfc724_mid,
attach_self_pubkey,
auth,
&shared_secret,
)
.await?;

View File

@@ -312,13 +312,17 @@ pub(crate) async fn send_handshake_message(
let rfc724_mid = create_outgoing_rfc724_mid();
let contact = Contact::get_by_id(context, invite.contact_id()).await?;
let recipient = contact.get_addr();
let alice_fp = invite.fingerprint().hex();
let auth = invite.authcode();
let shared_secret = format!("securejoin/{alice_fp}/{auth}");
let attach_self_pubkey = false;
let rendered_message = mimefactory::render_symm_encrypted_securejoin_message(
context,
"vc-request-pubkey",
&rfc724_mid,
attach_self_pubkey,
invite.authcode(),
auth,
&shared_secret,
)
.await?;

View File

@@ -12,7 +12,7 @@ use crate::key::self_fingerprint;
use crate::mimeparser::{GossipedKey, SystemMessage};
use crate::qr::Qr;
use crate::receive_imf::receive_imf;
use crate::stock_str::{self, messages_e2e_encrypted};
use crate::stock_str::{self, messages_e2ee_info_msg};
use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, get_chat_msg, sync,
@@ -109,7 +109,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
let mut i = 0..msg_cnt;
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await);
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await);
@@ -250,7 +250,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
let chat = alice.get_chat(&bob).await;
let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await;
assert!(msg.is_info());
let expected_text = messages_e2e_encrypted(&alice).await;
let expected_text = messages_e2ee_info_msg(&alice).await;
assert_eq!(msg.get_text(), expected_text);
}
@@ -295,7 +295,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
// The `SecurejoinWait` info message has been removed, but the e2ee notice remains.
let msg = get_chat_msg(&bob, bob_chat.get_id(), 0, 1).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await);
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -625,7 +625,7 @@ async fn test_secure_join_group_ex(v3: bool, remove_invite: bool) -> Result<()>
// - You added member bob@example.net
let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await;
assert!(msg.is_info());
let expected_text = messages_e2e_encrypted(&alice).await;
let expected_text = messages_e2ee_info_msg(&alice).await;
assert_eq!(msg.get_text(), expected_text);
}

View File

@@ -701,12 +701,12 @@ pub(crate) async fn add_self_recipients(
// and connection is frequently lost
// before receiving status line. NB: This is not a problem for chatmail servers, so `BccSelf`
// disabled by default is fine.
if context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty() {
if (context.get_config_delete_server_after().await? != Some(0)) || !recipients.is_empty() {
// Avoid sending unencrypted messages to all transports, chatmail relays won't accept
// them. Normally the user should have a non-chatmail primary transport to send unencrypted
// messages.
if encrypted {
for addr in context.get_secondary_self_addrs().await? {
for addr in context.get_published_secondary_self_addrs().await? {
recipients.push(addr);
}
}

View File

@@ -302,7 +302,7 @@ impl Sql {
/// otherwise allocates write connection.
///
/// 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
F: 'a + FnOnce(&mut Connection) -> Result<R> + Send,
R: Send + 'static,

View File

@@ -2343,6 +2343,26 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT;
.await?;
}
// Add an `is_published` flag to transports.
// Unpublished transports are not advertised to contacts,
// and self-sent messages are not sent there,
// so that we don't cause extra messages to the corresponding inbox,
// but can still receive messages from contacts who don't know the new relay addresses yet.
// The default is true, but when updating,
// existing secondary transports are set to unpublished,
// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
inc_and_check(&mut migration_version, 149)?;
if dbversion < migration_version {
sql.execute_migration(
"ALTER TABLE transports ADD COLUMN is_published INTEGER DEFAULT 1 NOT NULL;
UPDATE transports SET is_published=0 WHERE addr!=(
SELECT value FROM config WHERE keyname='configured_addr'
)",
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?

View File

@@ -391,6 +391,12 @@ https://delta.chat/donate"))]
Waiting for the device of %2$s to reply…"))]
SecureJoinBroadcastStarted = 203,
#[strum(props(fallback = "Channel name changed from \"%1$s\" to \"%2$s\"."))]
MsgBroadcastNameChanged = 204,
#[strum(props(fallback = "Channel image changed."))]
MsgBroadcastImgChanged = 205,
#[strum(props(
fallback = "The attachment contains anonymous usage statistics, which helps us improve Delta Chat. Thank you!"
))]
@@ -429,6 +435,9 @@ https://delta.chat/donate"))]
#[strum(props(fallback = "Chat description changed by %1$s."))]
MsgChatDescriptionChangedBy = 241,
#[strum(props(fallback = "Messages are end-to-end encrypted."))]
MessagesAreE2ee = 242,
}
impl StockMessage {
@@ -708,6 +717,19 @@ pub(crate) async fn secure_join_broadcast_started(
}
}
/// Stock string: `Channel name changed from "1%s" to "2$s".`
pub(crate) async fn msg_broadcast_name_changed(context: &Context, from: &str, to: &str) -> String {
translated(context, StockMessage::MsgBroadcastNameChanged)
.await
.replace1(from)
.replace2(to)
}
/// Stock string `Channel image changed.`
pub(crate) async fn msg_broadcast_img_changed(context: &Context) -> String {
translated(context, StockMessage::MsgBroadcastImgChanged).await
}
/// Stock string: `You reacted %1$s to "%2$s"` or `%1$s reacted %2$s to "%3$s"`.
pub(crate) async fn msg_reacted(
context: &Context,
@@ -1049,11 +1071,16 @@ pub(crate) async fn error_no_network(context: &Context) -> String {
translated(context, StockMessage::ErrorNoNetwork).await
}
/// Stock string: `Messages are end-to-end encrypted.`
pub(crate) async fn messages_e2e_encrypted(context: &Context) -> String {
/// Stock string: `Messages are end-to-end encrypted.`, used in info-messages, UI may add smth. as `Tap to learn more.`
pub(crate) async fn messages_e2ee_info_msg(context: &Context) -> String {
translated(context, StockMessage::ChatProtectionEnabled).await
}
/// Stock string: `Messages are end-to-end encrypted.`
pub(crate) async fn messages_are_e2ee(context: &Context) -> String {
translated(context, StockMessage::MessagesAreE2ee).await
}
/// Stock string: `Reply`.
pub(crate) async fn reply_noun(context: &Context) -> String {
translated(context, StockMessage::ReplyNoun).await

View File

@@ -65,6 +65,10 @@ pub(crate) struct TransportData {
/// Timestamp of when the transport was last time (re)configured.
pub(crate) timestamp: i64,
/// Whether the transport is published.
/// See [`Context::set_transport_unpublished`] for details.
pub(crate) is_published: bool,
}
#[derive(Debug, Serialize, Deserialize)]

View File

@@ -211,7 +211,7 @@ impl TestContextManager {
"INSERT OR IGNORE INTO transports (addr, entered_param, configured_param) VALUES (?, ?, ?)",
(
new_addr,
serde_json::to_string(&EnteredLoginParam::default()).unwrap(),
serde_json::to_string(&EnteredLoginParam{addr: new_addr.to_string(), ..Default::default()}).unwrap(),
format!(r#"{{"addr":"{new_addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"#)
),
).await.unwrap();

View File

@@ -195,7 +195,7 @@ async fn test_degrade_verified_oneonone_chat() -> Result<()> {
.await?;
let msg0 = get_chat_msg(&alice, alice_chat.id, 0, 1).await;
let enabled = stock_str::messages_e2e_encrypted(&alice).await;
let enabled = stock_str::messages_e2ee_info_msg(&alice).await;
assert_eq!(msg0.text, enabled);
assert_eq!(msg0.param.get_cmd(), SystemMessage::ChatE2ee);

View File

@@ -66,22 +66,6 @@ pub async fn lookup(
.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(
context: &Context,
namespace: Namespace,

View File

@@ -562,7 +562,15 @@ impl ConfiguredLoginParam {
entered_param: &EnteredLoginParam,
timestamp: i64,
) -> Result<()> {
save_transport(context, entered_param, &self.into(), timestamp).await?;
let is_published = true;
save_transport(
context,
entered_param,
&self.into(),
timestamp,
is_published,
)
.await?;
Ok(())
}
@@ -628,6 +636,7 @@ pub(crate) async fn save_transport(
entered_param: &EnteredLoginParam,
configured: &ConfiguredLoginParamJson,
add_timestamp: i64,
is_published: bool,
) -> Result<bool> {
let addr = addr_normalize(&configured.addr);
let configured_addr = context.get_config(Config::ConfiguredAddr).await?;
@@ -635,20 +644,23 @@ pub(crate) async fn save_transport(
let mut modified = context
.sql
.execute(
"INSERT INTO transports (addr, entered_param, configured_param, add_timestamp)
VALUES (?, ?, ?, ?)
"INSERT INTO transports (addr, entered_param, configured_param, add_timestamp, is_published)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (addr)
DO UPDATE SET entered_param=excluded.entered_param,
configured_param=excluded.configured_param,
add_timestamp=excluded.add_timestamp
add_timestamp=excluded.add_timestamp,
is_published=excluded.is_published
WHERE entered_param != excluded.entered_param
OR configured_param != excluded.configured_param
OR add_timestamp < excluded.add_timestamp",
OR add_timestamp < excluded.add_timestamp
OR is_published != excluded.is_published",
(
&addr,
serde_json::to_string(entered_param)?,
serde_json::to_string(configured)?,
add_timestamp,
is_published,
),
)
.await?
@@ -669,6 +681,9 @@ pub(crate) async fn save_transport(
pub(crate) async fn send_sync_transports(context: &Context) -> Result<()> {
info!(context, "Sending transport synchronization message.");
// Regenerate public key to include all transports.
context.self_public_key.lock().await.take();
// Synchronize all transport configurations.
//
// Transport with ID 1 is never synchronized
@@ -682,7 +697,7 @@ pub(crate) async fn send_sync_transports(context: &Context) -> Result<()> {
let transports = context
.sql
.query_map_vec(
"SELECT entered_param, configured_param, add_timestamp
"SELECT entered_param, configured_param, add_timestamp, is_published
FROM transports WHERE id>1",
(),
|row| {
@@ -691,10 +706,12 @@ pub(crate) async fn send_sync_transports(context: &Context) -> Result<()> {
let configured_json: String = row.get(1)?;
let configured: ConfiguredLoginParamJson = serde_json::from_str(&configured_json)?;
let timestamp: i64 = row.get(2)?;
let is_published: bool = row.get(3)?;
Ok(TransportData {
configured,
entered,
timestamp,
is_published,
})
},
)
@@ -733,9 +750,10 @@ pub(crate) async fn sync_transports(
configured,
entered,
timestamp,
is_published,
} in transports
{
modified |= save_transport(context, entered, configured, *timestamp).await?;
modified |= save_transport(context, entered, configured, *timestamp, *is_published).await?;
}
context
@@ -761,6 +779,7 @@ pub(crate) async fn sync_transports(
.await?;
if modified {
context.self_public_key.lock().await.take();
tokio::task::spawn(restart_io_if_running_boxed(context.clone()));
context.emit_event(EventType::TransportsModified);
}
@@ -780,7 +799,7 @@ pub(crate) async fn add_pseudo_transport(context: &Context, addr: &str) -> Resul
"INSERT INTO transports (addr, entered_param, configured_param) VALUES (?, ?, ?)",
(
addr,
serde_json::to_string(&EnteredLoginParam::default())?,
serde_json::to_string(&EnteredLoginParam{addr: addr.to_string(), ..Default::default()})?,
format!(r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"#)
),
)
@@ -789,283 +808,4 @@ pub(crate) async fn add_pseudo_transport(context: &Context, addr: &str) -> Resul
}
#[cfg(test)]
mod tests {
use super::*;
use crate::log::LogExt as _;
use crate::provider::get_provider_by_id;
use crate::test_utils::TestContext;
use crate::tools::time;
#[test]
fn test_configured_certificate_checks_display() {
use std::string::ToString;
assert_eq!(
"accept_invalid_certificates".to_string(),
ConfiguredCertificateChecks::AcceptInvalidCertificates.to_string()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_save_load_login_param() -> Result<()> {
let t = TestContext::new().await;
let param = ConfiguredLoginParam {
addr: "alice@example.org".to_string(),
imap: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "imap.example.com".to_string(),
port: 123,
security: ConnectionSecurity::Starttls,
},
user: "alice".to_string(),
}],
imap_user: "".to_string(),
imap_password: "foo".to_string(),
smtp: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "smtp.example.com".to_string(),
port: 456,
security: ConnectionSecurity::Tls,
},
user: "alice@example.org".to_string(),
}],
smtp_user: "".to_string(),
smtp_password: "bar".to_string(),
provider: None,
certificate_checks: ConfiguredCertificateChecks::Strict,
oauth2: false,
};
param
.clone()
.save_to_transports_table(&t, &EnteredLoginParam::default(), time())
.await?;
let expected_param = r#"{"addr":"alice@example.org","imap":[{"connection":{"host":"imap.example.com","port":123,"security":"Starttls"},"user":"alice"}],"imap_user":"","imap_password":"foo","smtp":[{"connection":{"host":"smtp.example.com","port":456,"security":"Tls"},"user":"alice@example.org"}],"smtp_user":"","smtp_password":"bar","provider_id":null,"certificate_checks":"Strict","oauth2":false}"#;
assert_eq!(
t.sql
.query_get_value::<String>("SELECT configured_param FROM transports", ())
.await?
.unwrap(),
expected_param
);
assert_eq!(t.is_configured().await?, true);
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
assert_eq!(param, loaded);
// Legacy ConfiguredImapCertificateChecks config is ignored
t.set_config(Config::ConfiguredImapCertificateChecks, Some("999"))
.await?;
assert!(ConfiguredLoginParam::load(&t).await.is_ok());
// Test that we don't panic on unknown ConfiguredImapCertificateChecks values.
let wrong_param = expected_param.replace("Strict", "Stricct");
assert_ne!(expected_param, wrong_param);
t.sql
.execute("UPDATE transports SET configured_param=?", (wrong_param,))
.await?;
assert!(ConfiguredLoginParam::load(&t).await.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_posteo_alias() -> Result<()> {
let t = TestContext::new().await;
let user = "alice@posteo.de";
// Alice has old config with "alice@posteo.at" address
// and "alice@posteo.de" username.
t.set_config(Config::Configured, Some("1")).await?;
t.set_config(Config::ConfiguredProvider, Some("posteo"))
.await?;
t.sql
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some("alice@posteo.at"))
.await?;
t.set_config(Config::ConfiguredMailServer, Some("posteo.de"))
.await?;
t.set_config(Config::ConfiguredMailPort, Some("993"))
.await?;
t.set_config(Config::ConfiguredMailSecurity, Some("1"))
.await?; // TLS
t.set_config(Config::ConfiguredMailUser, Some(user)).await?;
t.set_config(Config::ConfiguredMailPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredImapCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredSendServer, Some("posteo.de"))
.await?;
t.set_config(Config::ConfiguredSendPort, Some("465"))
.await?;
t.set_config(Config::ConfiguredSendSecurity, Some("1"))
.await?; // TLS
t.set_config(Config::ConfiguredSendUser, Some(user)).await?;
t.set_config(Config::ConfiguredSendPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredSmtpCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredServerFlags, Some("0"))
.await?;
let param = ConfiguredLoginParam {
addr: "alice@posteo.at".to_string(),
imap: vec![
ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "posteo.de".to_string(),
port: 993,
security: ConnectionSecurity::Tls,
},
user: user.to_string(),
},
ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "posteo.de".to_string(),
port: 143,
security: ConnectionSecurity::Starttls,
},
user: user.to_string(),
},
],
imap_user: "alice@posteo.de".to_string(),
imap_password: "foobarbaz".to_string(),
smtp: vec![
ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "posteo.de".to_string(),
port: 465,
security: ConnectionSecurity::Tls,
},
user: user.to_string(),
},
ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "posteo.de".to_string(),
port: 587,
security: ConnectionSecurity::Starttls,
},
user: user.to_string(),
},
],
smtp_user: "alice@posteo.de".to_string(),
smtp_password: "foobarbaz".to_string(),
provider: get_provider_by_id("posteo"),
certificate_checks: ConfiguredCertificateChecks::Strict,
oauth2: false,
};
let loaded = ConfiguredLoginParam::load_legacy(&t).await?.unwrap();
assert_eq!(loaded, param);
migrate_configured_login_param(&t).await;
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
assert_eq!(loaded, param);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_empty_server_list_legacy() -> Result<()> {
// Find a provider that does not have server list set.
//
// There is at least one such provider in the provider database.
let (domain, provider) = crate::provider::data::PROVIDER_DATA
.iter()
.find(|(_domain, provider)| provider.server.is_empty())
.unwrap();
let t = TestContext::new().await;
let addr = format!("alice@{domain}");
t.set_config(Config::Configured, Some("1")).await?;
t.set_config(Config::ConfiguredProvider, Some(provider.id))
.await?;
t.sql
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(&addr))
.await?;
t.set_config(Config::ConfiguredMailPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredImapCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredSendPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredSmtpCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredServerFlags, Some("0"))
.await?;
let loaded = ConfiguredLoginParam::load_legacy(&t).await?.unwrap();
assert_eq!(loaded.provider, Some(*provider));
assert_eq!(loaded.imap.is_empty(), false);
assert_eq!(loaded.smtp.is_empty(), false);
migrate_configured_login_param(&t).await;
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
assert_eq!(loaded.provider, Some(*provider));
assert_eq!(loaded.imap.is_empty(), false);
assert_eq!(loaded.smtp.is_empty(), false);
Ok(())
}
async fn migrate_configured_login_param(t: &TestContext) {
t.sql.execute("DROP TABLE transports;", ()).await.unwrap();
t.sql.set_raw_config_int("dbversion", 130).await.unwrap();
t.sql.run_migrations(t).await.log_err(t).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_empty_server_list() -> Result<()> {
// Find a provider that does not have server list set.
//
// There is at least one such provider in the provider database.
let (domain, provider) = crate::provider::data::PROVIDER_DATA
.iter()
.find(|(_domain, provider)| provider.server.is_empty())
.unwrap();
let t = TestContext::new().await;
let addr = format!("alice@{domain}");
ConfiguredLoginParam {
addr: addr.clone(),
imap: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "example.org".to_string(),
port: 100,
security: ConnectionSecurity::Tls,
},
user: addr.clone(),
}],
imap_user: addr.clone(),
imap_password: "foobarbaz".to_string(),
smtp: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "example.org".to_string(),
port: 100,
security: ConnectionSecurity::Tls,
},
user: addr.clone(),
}],
smtp_user: addr.clone(),
smtp_password: "foobarbaz".to_string(),
provider: Some(provider),
certificate_checks: ConfiguredCertificateChecks::Automatic,
oauth2: false,
}
.save_to_transports_table(&t, &EnteredLoginParam::default(), time())
.await?;
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
assert_eq!(loaded.provider, Some(*provider));
assert_eq!(loaded.imap.is_empty(), false);
assert_eq!(loaded.smtp.is_empty(), false);
assert_eq!(t.get_configured_provider().await?, Some(*provider));
Ok(())
}
}
mod transport_tests;

View File

@@ -0,0 +1,485 @@
use std::collections::BTreeSet;
use std::time::Duration;
use crate::tools::SystemTime;
use super::*;
use crate::log::LogExt as _;
use crate::provider::get_provider_by_id;
use crate::test_utils::TestContext;
use crate::test_utils::TestContextManager;
use crate::tools::time;
#[test]
fn test_configured_certificate_checks_display() {
use std::string::ToString;
assert_eq!(
"accept_invalid_certificates".to_string(),
ConfiguredCertificateChecks::AcceptInvalidCertificates.to_string()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_save_load_login_param() -> Result<()> {
let t = TestContext::new().await;
let param = ConfiguredLoginParam {
addr: "alice@example.org".to_string(),
imap: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "imap.example.com".to_string(),
port: 123,
security: ConnectionSecurity::Starttls,
},
user: "alice".to_string(),
}],
imap_user: "".to_string(),
imap_password: "foo".to_string(),
smtp: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "smtp.example.com".to_string(),
port: 456,
security: ConnectionSecurity::Tls,
},
user: "alice@example.org".to_string(),
}],
smtp_user: "".to_string(),
smtp_password: "bar".to_string(),
provider: None,
certificate_checks: ConfiguredCertificateChecks::Strict,
oauth2: false,
};
param
.clone()
.save_to_transports_table(&t, &EnteredLoginParam::default(), time())
.await?;
let expected_param = r#"{"addr":"alice@example.org","imap":[{"connection":{"host":"imap.example.com","port":123,"security":"Starttls"},"user":"alice"}],"imap_user":"","imap_password":"foo","smtp":[{"connection":{"host":"smtp.example.com","port":456,"security":"Tls"},"user":"alice@example.org"}],"smtp_user":"","smtp_password":"bar","provider_id":null,"certificate_checks":"Strict","oauth2":false}"#;
assert_eq!(
t.sql
.query_get_value::<String>("SELECT configured_param FROM transports", ())
.await?
.unwrap(),
expected_param
);
assert_eq!(t.is_configured().await?, true);
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
assert_eq!(param, loaded);
// Legacy ConfiguredImapCertificateChecks config is ignored
t.set_config(Config::ConfiguredImapCertificateChecks, Some("999"))
.await?;
assert!(ConfiguredLoginParam::load(&t).await.is_ok());
// Test that we don't panic on unknown ConfiguredImapCertificateChecks values.
let wrong_param = expected_param.replace("Strict", "Stricct");
assert_ne!(expected_param, wrong_param);
t.sql
.execute("UPDATE transports SET configured_param=?", (wrong_param,))
.await?;
assert!(ConfiguredLoginParam::load(&t).await.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_posteo_alias() -> Result<()> {
let t = TestContext::new().await;
let user = "alice@posteo.de";
// Alice has old config with "alice@posteo.at" address
// and "alice@posteo.de" username.
t.set_config(Config::Configured, Some("1")).await?;
t.set_config(Config::ConfiguredProvider, Some("posteo"))
.await?;
t.sql
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some("alice@posteo.at"))
.await?;
t.set_config(Config::ConfiguredMailServer, Some("posteo.de"))
.await?;
t.set_config(Config::ConfiguredMailPort, Some("993"))
.await?;
t.set_config(Config::ConfiguredMailSecurity, Some("1"))
.await?; // TLS
t.set_config(Config::ConfiguredMailUser, Some(user)).await?;
t.set_config(Config::ConfiguredMailPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredImapCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredSendServer, Some("posteo.de"))
.await?;
t.set_config(Config::ConfiguredSendPort, Some("465"))
.await?;
t.set_config(Config::ConfiguredSendSecurity, Some("1"))
.await?; // TLS
t.set_config(Config::ConfiguredSendUser, Some(user)).await?;
t.set_config(Config::ConfiguredSendPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredSmtpCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredServerFlags, Some("0"))
.await?;
let param = ConfiguredLoginParam {
addr: "alice@posteo.at".to_string(),
imap: vec![
ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "posteo.de".to_string(),
port: 993,
security: ConnectionSecurity::Tls,
},
user: user.to_string(),
},
ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "posteo.de".to_string(),
port: 143,
security: ConnectionSecurity::Starttls,
},
user: user.to_string(),
},
],
imap_user: "alice@posteo.de".to_string(),
imap_password: "foobarbaz".to_string(),
smtp: vec![
ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "posteo.de".to_string(),
port: 465,
security: ConnectionSecurity::Tls,
},
user: user.to_string(),
},
ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "posteo.de".to_string(),
port: 587,
security: ConnectionSecurity::Starttls,
},
user: user.to_string(),
},
],
smtp_user: "alice@posteo.de".to_string(),
smtp_password: "foobarbaz".to_string(),
provider: get_provider_by_id("posteo"),
certificate_checks: ConfiguredCertificateChecks::Strict,
oauth2: false,
};
let loaded = ConfiguredLoginParam::load_legacy(&t).await?.unwrap();
assert_eq!(loaded, param);
migrate_configured_login_param(&t).await;
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
assert_eq!(loaded, param);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_empty_server_list_legacy() -> Result<()> {
// Find a provider that does not have server list set.
//
// There is at least one such provider in the provider database.
let (domain, provider) = crate::provider::data::PROVIDER_DATA
.iter()
.find(|(_domain, provider)| provider.server.is_empty())
.unwrap();
let t = TestContext::new().await;
let addr = format!("alice@{domain}");
t.set_config(Config::Configured, Some("1")).await?;
t.set_config(Config::ConfiguredProvider, Some(provider.id))
.await?;
t.sql
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(&addr))
.await?;
t.set_config(Config::ConfiguredMailPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredImapCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredSendPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredSmtpCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredServerFlags, Some("0"))
.await?;
let loaded = ConfiguredLoginParam::load_legacy(&t).await?.unwrap();
assert_eq!(loaded.provider, Some(*provider));
assert_eq!(loaded.imap.is_empty(), false);
assert_eq!(loaded.smtp.is_empty(), false);
migrate_configured_login_param(&t).await;
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
assert_eq!(loaded.provider, Some(*provider));
assert_eq!(loaded.imap.is_empty(), false);
assert_eq!(loaded.smtp.is_empty(), false);
Ok(())
}
async fn migrate_configured_login_param(t: &TestContext) {
t.sql.execute("DROP TABLE transports;", ()).await.unwrap();
t.sql.set_raw_config_int("dbversion", 130).await.unwrap();
t.sql.run_migrations(t).await.log_err(t).ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_empty_server_list() -> Result<()> {
// Find a provider that does not have server list set.
//
// There is at least one such provider in the provider database.
let (domain, provider) = crate::provider::data::PROVIDER_DATA
.iter()
.find(|(_domain, provider)| provider.server.is_empty())
.unwrap();
let t = TestContext::new().await;
let addr = format!("alice@{domain}");
dummy_configured_login_param(&addr, Some(provider))
.save_to_transports_table(&t, &EnteredLoginParam::default(), time())
.await?;
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
assert_eq!(loaded.provider, Some(*provider));
assert_eq!(loaded.imap.is_empty(), false);
assert_eq!(loaded.smtp.is_empty(), false);
assert_eq!(t.get_configured_provider().await?, Some(*provider));
Ok(())
}
fn dummy_configured_login_param(
addr: &str,
provider: Option<&'static Provider>,
) -> ConfiguredLoginParam {
ConfiguredLoginParam {
addr: addr.to_string(),
imap: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "example.org".to_string(),
port: 100,
security: ConnectionSecurity::Tls,
},
user: addr.to_string(),
}],
imap_user: addr.to_string(),
imap_password: "foobarbaz".to_string(),
smtp: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "example.org".to_string(),
port: 100,
security: ConnectionSecurity::Tls,
},
user: addr.to_string(),
}],
smtp_user: addr.to_string(),
smtp_password: "foobarbaz".to_string(),
provider,
certificate_checks: ConfiguredCertificateChecks::Automatic,
oauth2: false,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_published_flag() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await;
for a in [alice, alice2] {
a.set_config_bool(Config::SyncMsgs, true).await?;
a.set_config_bool(Config::BccSelf, true).await?;
}
let bob = &tcm.bob().await;
check_addrs(
alice,
alice2,
bob,
Addresses {
primary: "alice@example.org",
secondary_published: &[],
secondary_unpublished: &[],
},
)
.await;
dummy_configured_login_param("alice@otherprovider.com", None)
.save_to_transports_table(
alice,
&EnteredLoginParam {
addr: "alice@otherprovider.com".to_string(),
..Default::default()
},
time(),
)
.await?;
send_sync_transports(alice).await?;
sync_and_check_recipients(alice, alice2, "alice@otherprovider.com alice@example.org").await;
check_addrs(
alice,
alice2,
bob,
Addresses {
primary: "alice@example.org",
secondary_published: &["alice@otherprovider.com"],
secondary_unpublished: &[],
},
)
.await;
assert_eq!(
alice
.set_transport_unpublished("alice@example.org", true)
.await
.unwrap_err()
.to_string(),
"Can't set primary relay as unpublished"
);
// Make sure that the newly generated key has a newer timestamp,
// so that it is recognized by Bob:
SystemTime::shift(Duration::from_secs(2));
alice
.set_transport_unpublished("alice@otherprovider.com", true)
.await?;
sync_and_check_recipients(alice, alice2, "alice@example.org").await;
check_addrs(
alice,
alice2,
bob,
Addresses {
primary: "alice@example.org",
secondary_published: &[],
secondary_unpublished: &["alice@otherprovider.com"],
},
)
.await;
SystemTime::shift(Duration::from_secs(2));
alice
.set_config(Config::ConfiguredAddr, Some("alice@otherprovider.com"))
.await?;
sync_and_check_recipients(alice, alice2, "alice@example.org alice@otherprovider.com").await;
check_addrs(
alice,
alice2,
bob,
Addresses {
primary: "alice@otherprovider.com",
secondary_published: &["alice@example.org"],
secondary_unpublished: &[],
},
)
.await;
Ok(())
}
struct Addresses {
primary: &'static str,
secondary_published: &'static [&'static str],
secondary_unpublished: &'static [&'static str],
}
async fn check_addrs(
alice: &TestContext,
alice2: &TestContext,
bob: &TestContext,
addresses: Addresses,
) {
fn assert_eq(left: Vec<String>, right: Vec<&'static str>) {
assert_eq!(
left.iter().map(|s| s.as_str()).collect::<BTreeSet<_>>(),
right.into_iter().collect::<BTreeSet<_>>(),
)
}
let published_self_addrs = concat(&[addresses.secondary_published, &[addresses.primary]]);
for a in [alice2, alice] {
assert_eq(
a.get_all_self_addrs().await.unwrap(),
concat(&[
addresses.secondary_published,
addresses.secondary_unpublished,
&[addresses.primary],
]),
);
assert_eq(
a.get_published_self_addrs().await.unwrap(),
published_self_addrs.clone(),
);
assert_eq(
a.get_secondary_self_addrs().await.unwrap(),
concat(&[
addresses.secondary_published,
addresses.secondary_unpublished,
]),
);
assert_eq(
a.get_published_secondary_self_addrs().await.unwrap(),
concat(&[addresses.secondary_published]),
);
for transport in a.list_transports().await.unwrap() {
if addresses.primary == transport.param.addr
|| addresses
.secondary_published
.contains(&transport.param.addr.as_str())
{
assert_eq!(transport.is_unpublished, false);
} else if addresses
.secondary_unpublished
.contains(&transport.param.addr.as_str())
{
assert_eq!(transport.is_unpublished, true);
} else {
panic!("Unexpected transport {transport:?}");
}
}
let alice_bob_chat_id = a.create_chat_id(bob).await;
let sent = a.send_text(alice_bob_chat_id, "hi").await;
assert_eq!(
sent.recipients,
format!("bob@example.net {}", published_self_addrs.join(" ")),
"{} is sending to the wrong set of recipients",
a.name()
);
let bob_alice_chat_id = bob.recv_msg(&sent).await.chat_id;
bob_alice_chat_id.accept(bob).await.unwrap();
let answer = bob.send_text(bob_alice_chat_id, "hi back").await;
assert_eq(
answer.recipients.split(' ').map(Into::into).collect(),
concat(&[&published_self_addrs, &["bob@example.net"]]),
);
}
}
fn concat(slices: &[&[&'static str]]) -> Vec<&'static str> {
let mut res = vec![];
for s in slices {
res.extend(*s);
}
res
}
pub async fn sync_and_check_recipients(from: &TestContext, to: &TestContext, recipients: &str) {
from.send_sync_msg().await.unwrap();
let sync_msg = from.pop_sent_msg().await;
assert_eq!(sync_msg.recipients, recipients);
to.recv_msg_trash(&sync_msg).await;
}

View File

@@ -1,6 +1,6 @@
OutBroadcast#Chat#1001: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png
--------------------------------------------------------------------------------
Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
Msg#1002🔒: Me (Contact#Contact#Self): You changed the group image. [INFO] √
Msg#1002🔒: Me (Contact#Contact#Self): Channel image changed. [INFO] √
Msg#1005🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
--------------------------------------------------------------------------------