mirror of
https://github.com/chatmail/core.git
synced 2026-05-18 14:26:31 +03:00
Compare commits
25 Commits
d2weber/c_
...
hoc/unpubl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b08ba4bb8c | ||
|
|
b94d9f95b6 | ||
|
|
39dc495944 | ||
|
|
bf02785a36 | ||
|
|
01b2aa0f66 | ||
|
|
fb46c34b55 | ||
|
|
9393753190 | ||
|
|
d9056fd187 | ||
|
|
7b17b1f8b8 | ||
|
|
d8d7f12af0 | ||
|
|
0150d38ddd | ||
|
|
11b6a108f5 | ||
|
|
54858361a9 | ||
|
|
6a705a3ef6 | ||
|
|
a23e41ea6d | ||
|
|
bdca3e5c09 | ||
|
|
a61a25f139 | ||
|
|
5404e683eb | ||
|
|
80acc9d467 | ||
|
|
3c5af7a559 | ||
|
|
f7e9973fb4 | ||
|
|
c0a3d77301 | ||
|
|
9891c2a531 | ||
|
|
f85c625799 | ||
|
|
b30f93a57d |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
86
CHANGELOG.md
86
CHANGELOG.md
@@ -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
34
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
* @}
|
||||
*/
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
1
deltachat-jsonrpc/c/.gitignore
vendored
1
deltachat-jsonrpc/c/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
generated
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "2.44.0-dev"
|
||||
"version": "2.46.0-dev"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.44.0-dev"
|
||||
"version": "2.46.0-dev"
|
||||
}
|
||||
|
||||
101
example.cpp
101
example.cpp
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026-02-27
|
||||
2026-03-14
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
88
src/chat.rs
88
src/chat.rs
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(¶m)?;
|
||||
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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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?);
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
246
src/decrypt.rs
246
src/decrypt.rs
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
188
src/key.rs
188
src/key.rs
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", ())
|
||||
|
||||
258
src/mimeparser/shared_secret_decryption_tests.rs
Normal file
258
src/mimeparser/shared_secret_decryption_tests.rs
Normal 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
|
||||
}
|
||||
421
src/pgp.rs
421
src/pgp.rs
@@ -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(¬ation.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
16
src/token.rs
16
src/token.rs
@@ -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,
|
||||
|
||||
316
src/transport.rs
316
src/transport.rs
@@ -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;
|
||||
|
||||
485
src/transport/transport_tests.rs
Normal file
485
src/transport/transport_tests.rs
Normal 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;
|
||||
}
|
||||
@@ -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] √
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user