mirror of
https://github.com/chatmail/core.git
synced 2026-10-08 22:20:17 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b0470da8 |
@@ -30,9 +30,6 @@ jobs:
|
||||
name: Lint Rust
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# Tests always unwind: match it so dependencies are only checked once.
|
||||
CARGO_PROFILE_DEV_PANIC: unwind
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -51,6 +48,8 @@ jobs:
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Run clippy
|
||||
run: scripts/clippy.sh
|
||||
- name: Check with all features
|
||||
run: cargo check --workspace --all-targets --all-features
|
||||
- name: Check with only default features
|
||||
run: cargo check --all-targets
|
||||
|
||||
@@ -140,12 +139,12 @@ jobs:
|
||||
- name: Tests
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
run: cargo nextest run --workspace --exclude deltachat-jsonrpc-bindings --locked
|
||||
run: cargo nextest run --workspace --locked
|
||||
|
||||
- name: Doc-Tests
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
run: cargo test --workspace --exclude deltachat-jsonrpc-bindings --locked --doc
|
||||
run: cargo test --workspace --locked --doc
|
||||
|
||||
- name: Test cargo vendor
|
||||
run: cargo vendor
|
||||
|
||||
Generated
+4
-3
@@ -1356,7 +1356,6 @@ dependencies = [
|
||||
"futures",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"http-body-util",
|
||||
"humansize",
|
||||
"hyper",
|
||||
@@ -1407,6 +1406,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
@@ -1485,6 +1485,7 @@ dependencies = [
|
||||
"deltachat",
|
||||
"deltachat-jsonrpc",
|
||||
"futures-lite",
|
||||
"libc",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4247,9 +4248,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pgp"
|
||||
version = "0.21.0"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
|
||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
|
||||
+2
-2
@@ -60,7 +60,6 @@ fd-lock = "4"
|
||||
futures-lite = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
hex = "0.4.0"
|
||||
hkdf = { version = "0.12", default-features = false }
|
||||
http-body-util = "0.1.3"
|
||||
humansize = "2"
|
||||
hyper = "1"
|
||||
@@ -78,7 +77,7 @@ num-derive = "0.4"
|
||||
num-traits = { workspace = true }
|
||||
parking_lot = "0.12.4"
|
||||
percent-encoding = "2.3"
|
||||
pgp = { version = "0.21.0", features = ["pqc"], default-features = false }
|
||||
pgp = { version = "0.20.0", features = ["draft-pqc"], default-features = false }
|
||||
pin-project = "1"
|
||||
qrcodegen = "1.7.0"
|
||||
quick-xml = { version = "0.41", features = ["escape-html"] }
|
||||
@@ -105,6 +104,7 @@ astral-tokio-tar = { version = "0.7.0", default-features = false }
|
||||
tokio-util = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
|
||||
toml = "0.9"
|
||||
tracing = "0.1.41"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
walkdir = "2.5.0"
|
||||
|
||||
@@ -3543,6 +3543,28 @@ uint32_t dc_chatlist_get_msg_id (const dc_chatlist_t* chatlist, siz
|
||||
dc_lot_t* dc_chatlist_get_summary (const dc_chatlist_t* chatlist, size_t index, dc_chat_t* chat);
|
||||
|
||||
|
||||
/**
|
||||
* Create a chatlist summary item when the chatlist object is already unref()'d.
|
||||
*
|
||||
* This function is similar to dc_chatlist_get_summary(), however,
|
||||
* it takes the chat ID and the message ID as returned by dc_chatlist_get_chat_id() and dc_chatlist_get_msg_id()
|
||||
* as arguments. The chatlist object itself is not needed directly.
|
||||
*
|
||||
* This maybe useful if you convert the complete object into a different representation
|
||||
* as done e.g. in the node-bindings.
|
||||
* If you have access to the chatlist object in some way, using this function is not recommended,
|
||||
* use dc_chatlist_get_summary() in this case instead.
|
||||
*
|
||||
* @memberof dc_context_t
|
||||
* @param context The context object.
|
||||
* @param chat_id The chat ID to get a summary for.
|
||||
* @param msg_id The message ID to get a summary for.
|
||||
* @return The summary as an dc_lot_t object, see dc_chatlist_get_summary() for details.
|
||||
* Must be freed using dc_lot_unref(). NULL is never returned.
|
||||
*/
|
||||
dc_lot_t* dc_chatlist_get_summary2 (dc_context_t* context, uint32_t chat_id, uint32_t msg_id);
|
||||
|
||||
|
||||
/**
|
||||
* Get info summary for a chat, in JSON format.
|
||||
*
|
||||
|
||||
@@ -47,6 +47,7 @@ mod dc_array;
|
||||
mod lot;
|
||||
|
||||
mod string;
|
||||
use deltachat::chatlist::Chatlist;
|
||||
|
||||
use self::string::*;
|
||||
|
||||
@@ -2843,6 +2844,34 @@ pub unsafe extern "C" fn dc_chatlist_get_summary(
|
||||
Box::into_raw(Box::new(summary.into()))
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dc_chatlist_get_summary2(
|
||||
context: *mut dc_context_t,
|
||||
chat_id: u32,
|
||||
msg_id: u32,
|
||||
) -> *mut dc_lot_t {
|
||||
if context.is_null() {
|
||||
eprintln!("ignoring careless call to dc_chatlist_get_summary2()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
let ctx = unsafe { &*context };
|
||||
let msg_id = if msg_id == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(MsgId::new(msg_id))
|
||||
};
|
||||
let summary = block_on(Chatlist::get_summary2(
|
||||
ctx,
|
||||
ChatId::new(chat_id),
|
||||
msg_id,
|
||||
None,
|
||||
))
|
||||
.context("get_summary2 failed")
|
||||
.log_err(ctx)
|
||||
.unwrap_or_default();
|
||||
Box::into_raw(Box::new(summary.into()))
|
||||
}
|
||||
|
||||
// dc_chat_t
|
||||
|
||||
/// FFI struct for [dc_chat_t]
|
||||
|
||||
@@ -31,6 +31,8 @@ pub enum ChatListItemFetchResult {
|
||||
summary_text1: String,
|
||||
summary_text2: String,
|
||||
summary_status: u32,
|
||||
/// showing preview if last chat message is image
|
||||
summary_preview_image: Option<String>,
|
||||
|
||||
/// True if the chat is encrypted.
|
||||
/// This means that all messages in the chat are encrypted,
|
||||
@@ -101,6 +103,8 @@ pub(crate) async fn get_chat_list_item_by_id(
|
||||
let summary_text1 = summary.prefix.map_or_else(String::new, |s| s.to_string());
|
||||
let summary_text2 = summary.text.to_owned();
|
||||
|
||||
let summary_preview_image = summary.thumbnail_path;
|
||||
|
||||
let visibility = chat.get_visibility();
|
||||
|
||||
let avatar_path = chat
|
||||
@@ -153,6 +157,7 @@ pub(crate) async fn get_chat_list_item_by_id(
|
||||
summary_text1,
|
||||
summary_text2,
|
||||
summary_status: summary.state.to_u32().expect("impossible"), // idea and a function to transform the constant to strings? or return string enum
|
||||
summary_preview_image,
|
||||
is_encrypted: chat.is_encrypted(ctx).await?,
|
||||
is_group: chat.get_type() == Chattype::Group,
|
||||
fresh_message_counter,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def anonymous_mib(pid):
|
||||
with open(f"/proc/{pid}/smaps_rollup") as f:
|
||||
for line in f:
|
||||
if line.startswith("Anonymous:"):
|
||||
return int(line.split()[1]) // 1024
|
||||
raise LookupError("Anonymous")
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="reads /proc")
|
||||
def test_attachment_memory_is_returned(acf, rpc, tmp_path):
|
||||
# See also comments for `tune_malloc` in `deltachat-rpc-server/src/main.rs`
|
||||
ac1, ac2 = acf.get_online_accounts(2)
|
||||
chat1 = acf.get_accepted_chat(ac1, ac2)
|
||||
chat2 = ac2.create_chat(ac1)
|
||||
blob = tmp_path / "blob.bin"
|
||||
blob.write_bytes(os.urandom(20 << 20))
|
||||
before = anonymous_mib(rpc.process.pid)
|
||||
|
||||
for sender_chat, receiver in ((chat1, ac2), (chat2, ac1)):
|
||||
sender_chat.send_file(str(blob))
|
||||
event = receiver.wait_for_incoming_msg_event()
|
||||
assert receiver.get_message_by_id(event.msg_id).get_snapshot().file_bytes == 20 << 20
|
||||
for ac in (ac1, ac2):
|
||||
rpc.wait_for_all_work_done(ac.id)
|
||||
|
||||
grown = anonymous_mib(rpc.process.pid) - before
|
||||
assert grown < 64, f"the server kept {grown} MiB after two 20 MiB attachments"
|
||||
@@ -14,6 +14,7 @@ deltachat = { workspace = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
futures-lite = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -22,8 +22,32 @@ use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use yerpc::{RpcClient, RpcSession};
|
||||
|
||||
/// Pins Linux glibc's mmap threshold so that freed message buffers go back to the kernel.
|
||||
///
|
||||
/// See M_MMAP_THRESHOLD in <https://man7.org/linux/man-pages/man3/mallopt.3.html>:
|
||||
/// glibc by default starts with a M_MMAP_THRESHOLD threshold of 128 KiB
|
||||
/// but raises it to the size of every freed block that exceeds it,
|
||||
/// up to 32 MiB on 64-bit systems,
|
||||
/// and trims the heap only from its top end once twice that much is free.
|
||||
/// Fixating the threshold disables the adjustment: allocations at or above it
|
||||
/// that the free list cannot satisfy are mmapped and unmapped on free,
|
||||
/// at the price of the kernel zeroing each such buffer after unmap.
|
||||
/// Large message processing (allocations above 128KiB) very slightly slows
|
||||
/// down to the kernel zeroing the buffers, but it's hardly measurable,
|
||||
/// while overall process memory allocation behaviour significantly improves.
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
fn tune_malloc() {
|
||||
unsafe {
|
||||
libc::mallopt(libc::M_MMAP_THRESHOLD, 128 * 1024);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
|
||||
fn tune_malloc() {}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
tune_malloc();
|
||||
// Logs from `log` crate and traces from `tracing` crate
|
||||
// are configurable with `RUST_LOG` environment variable
|
||||
// and go to stderr to avoid interfering with JSON-RPC using stdout.
|
||||
|
||||
@@ -456,21 +456,6 @@ CREATE TABLE smtp_status_updates (
|
||||
descr TEXT NOT NULL -- text to send along with the updates
|
||||
);
|
||||
|
||||
-- Table to record the successful usage transports for sending.
|
||||
-- Sorting the table by rowid in descending order
|
||||
-- returns most recently successfully used transport first.
|
||||
CREATE TABLE smtp_success (
|
||||
-- Sequentially increasing ID of the success.
|
||||
-- Transport with the highest ID is to be used first.
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
|
||||
-- ID of the transport that was used to send a message.
|
||||
transport_id INTEGER UNIQUE NOT NULL,
|
||||
|
||||
-- Delete `smtp_success` rows when the transport is deleted.
|
||||
FOREIGN KEY(transport_id) REFERENCES transports(id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
-- Table of "sync items" to be grouped into sync messages
|
||||
-- and sent to own devices.
|
||||
CREATE TABLE multi_device_sync (
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@
|
||||
#
|
||||
# To automatically fix warnings, run
|
||||
# scripts/clippy.sh --fix --allow-dirty
|
||||
cargo clippy --locked --workspace --exclude deltachat-jsonrpc-bindings --all-targets --all-features "$@" -- -D warnings
|
||||
cargo clippy --locked --workspace --all-targets --all-features "$@" -- -D warnings
|
||||
|
||||
+22
-3
@@ -76,8 +76,12 @@ impl Accounts {
|
||||
Accounts::open(events, dir, writable).await
|
||||
}
|
||||
|
||||
fn log_info(&self, file: &str, line: u32, msg: String) {
|
||||
self.emit_event(EventType::Info(format!("{file}:{line}: {msg}")));
|
||||
/// Get the ID used to log events.
|
||||
///
|
||||
/// Account manager logs events with ID 0
|
||||
/// which is not used by any accounts.
|
||||
fn get_id(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
/// Ensures the accounts directory and config file exist.
|
||||
@@ -391,6 +395,11 @@ impl Accounts {
|
||||
"Starting background fetch for {n_accounts} accounts."
|
||||
)),
|
||||
});
|
||||
::tracing::event!(
|
||||
::tracing::Level::INFO,
|
||||
account_id = 0,
|
||||
"Starting background fetch for {n_accounts} accounts."
|
||||
);
|
||||
let mut set = JoinSet::new();
|
||||
for account in accounts {
|
||||
set.spawn(async move {
|
||||
@@ -406,6 +415,11 @@ impl Accounts {
|
||||
"Finished background fetch for {n_accounts} accounts."
|
||||
)),
|
||||
});
|
||||
::tracing::event!(
|
||||
::tracing::Level::INFO,
|
||||
account_id = 0,
|
||||
"Finished background fetch for {n_accounts} accounts."
|
||||
);
|
||||
}
|
||||
|
||||
/// Auxiliary function for [Accounts::background_fetch].
|
||||
@@ -448,6 +462,11 @@ impl Accounts {
|
||||
id: 0,
|
||||
typ: EventType::Warning("Background fetch timed out.".to_string()),
|
||||
});
|
||||
::tracing::event!(
|
||||
::tracing::Level::WARN,
|
||||
account_id = 0,
|
||||
"Background fetch timed out."
|
||||
);
|
||||
}
|
||||
events.emit(Event {
|
||||
id: 0,
|
||||
@@ -530,7 +549,7 @@ impl Accounts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a single event with ID 0, which is not used by any accounts.
|
||||
/// Emits a single event.
|
||||
pub fn emit_event(&self, event: EventType) {
|
||||
self.events.emit(Event { id: 0, typ: event })
|
||||
}
|
||||
|
||||
@@ -478,10 +478,6 @@ pub enum Config {
|
||||
/// and incoming unencrypted messages are not fetched and not processed.
|
||||
#[strum(props(default = "1"))]
|
||||
ForceEncryption,
|
||||
|
||||
/// Generate Autocrypt 2 instead of Autocrypt 1 key.
|
||||
#[strum(props(default = "1"))]
|
||||
Autocrypt2,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
||||
@@ -1030,10 +1030,6 @@ impl Context {
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"autocrypt2",
|
||||
self.get_config_bool(Config::Autocrypt2).await?.to_string(),
|
||||
);
|
||||
|
||||
let elapsed = time_elapsed(&self.creation_time);
|
||||
res.insert("uptime", duration_to_str(elapsed));
|
||||
|
||||
+8
-14
@@ -17,12 +17,10 @@ use pgp::packet::{
|
||||
SubpacketData,
|
||||
};
|
||||
use pgp::ser::Serialize;
|
||||
use pgp::types::Timestamp as PgpTimestamp;
|
||||
use pgp::types::{CompressionAlgorithm, KeyDetails, KeyVersion};
|
||||
use rand_old::thread_rng;
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::log::LogExt;
|
||||
@@ -127,11 +125,14 @@ pub trait DcKey: Serialize + Deserializable + Clone {
|
||||
|
||||
/// 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.");
|
||||
|
||||
// Make sure timestamp of created signatures
|
||||
// is not in the past compared to the primary key timestamp.
|
||||
let timestamp = std::cmp::max(
|
||||
@@ -155,7 +156,7 @@ pub(crate) fn secret_key_to_public_key(
|
||||
};
|
||||
|
||||
Ok(vec![
|
||||
Subpacket::critical(SubpacketData::SignatureCreationTime(timestamp))?,
|
||||
Subpacket::regular(SubpacketData::SignatureCreationTime(timestamp))?,
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(
|
||||
signed_secret_key.fingerprint(),
|
||||
))?,
|
||||
@@ -304,7 +305,7 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
|
||||
let addr = context.get_primary_self_addr().await?;
|
||||
let all_addrs = context.get_self_addrs().await?.join(",");
|
||||
let signed_public_key =
|
||||
secret_key_to_public_key(signed_secret_key, timestamp, &addr, &all_addrs)?;
|
||||
secret_key_to_public_key(context, signed_secret_key, timestamp, &addr, &all_addrs)?;
|
||||
*lock = Some(signed_public_key.clone());
|
||||
|
||||
Ok(Some(signed_public_key))
|
||||
@@ -480,16 +481,9 @@ async fn generate_keypair(context: &Context) -> Result<SignedSecretKey> {
|
||||
None => {
|
||||
let start = tools::Time::now();
|
||||
info!(context, "Generating keypair.");
|
||||
let keypair = if context.get_config_bool(Config::Autocrypt2).await? {
|
||||
let now = PgpTimestamp::now();
|
||||
Handle::current()
|
||||
.spawn_blocking(move || crate::pgp::autocrypt2::create_autocrypt2_keypair(now))
|
||||
.await??
|
||||
} else {
|
||||
Handle::current()
|
||||
.spawn_blocking(move || crate::pgp::create_keypair(addr))
|
||||
.await??
|
||||
};
|
||||
let keypair = Handle::current()
|
||||
.spawn_blocking(move || crate::pgp::create_keypair(addr))
|
||||
.await??;
|
||||
|
||||
store_self_keypair(context, &keypair).await?;
|
||||
info!(
|
||||
|
||||
+30
-23
@@ -3,7 +3,6 @@
|
||||
#![allow(missing_docs)]
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
|
||||
mod stream;
|
||||
|
||||
@@ -13,9 +12,15 @@ macro_rules! info {
|
||||
($ctx:expr, $msg:expr) => {
|
||||
info!($ctx, $msg,)
|
||||
};
|
||||
($ctx:expr, $msg:expr, $($args:expr),* $(,)?) => {
|
||||
$ctx.log_info(file!(), line!(), format!($msg, $($args),*))
|
||||
};
|
||||
($ctx:expr, $msg:expr, $($args:expr),* $(,)?) => {{
|
||||
let formatted = format!($msg, $($args),*);
|
||||
let full = format!("{file}:{line}: {msg}",
|
||||
file = file!(),
|
||||
line = line!(),
|
||||
msg = &formatted);
|
||||
::tracing::event!(::tracing::Level::INFO, account_id = $ctx.get_id(), "{}", &formatted);
|
||||
$ctx.emit_event($crate::EventType::Info(full));
|
||||
}};
|
||||
}
|
||||
|
||||
// Workaround for <https://github.com/rust-lang/rust/issues/133708>.
|
||||
@@ -25,9 +30,15 @@ mod warn_macro_mod {
|
||||
($ctx:expr, $msg:expr) => {
|
||||
warn_macro!($ctx, $msg,)
|
||||
};
|
||||
($ctx:expr, $msg:expr, $($args:expr),* $(,)?) => {
|
||||
$ctx.log_warn(file!(), line!(), format!($msg, $($args),*))
|
||||
};
|
||||
($ctx:expr, $msg:expr, $($args:expr),* $(,)?) => {{
|
||||
let formatted = format!($msg, $($args),*);
|
||||
let full = format!("{file}:{line}: {msg}",
|
||||
file = file!(),
|
||||
line = line!(),
|
||||
msg = &formatted);
|
||||
::tracing::event!(::tracing::Level::WARN, account_id = $ctx.get_id(), "{}", &formatted);
|
||||
$ctx.emit_event($crate::EventType::Warning(full));
|
||||
}};
|
||||
}
|
||||
|
||||
pub(crate) use warn_macro;
|
||||
@@ -39,25 +50,15 @@ macro_rules! error {
|
||||
($ctx:expr, $msg:expr) => {
|
||||
error!($ctx, $msg,)
|
||||
};
|
||||
($ctx:expr, $msg:expr, $($args:expr),* $(,)?) => {
|
||||
$ctx.log_error(format!($msg, $($args),*))
|
||||
};
|
||||
($ctx:expr, $msg:expr, $($args:expr),* $(,)?) => {{
|
||||
let formatted = format!($msg, $($args),*);
|
||||
::tracing::event!(::tracing::Level::ERROR, account_id = $ctx.get_id(), "{}", &formatted);
|
||||
$ctx.set_last_error(&formatted);
|
||||
$ctx.emit_event($crate::EventType::Error(formatted));
|
||||
}};
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub(crate) fn log_info(&self, file: &str, line: u32, msg: String) {
|
||||
self.emit_event(EventType::Info(format!("{file}:{line}: {msg}")));
|
||||
}
|
||||
|
||||
pub(crate) fn log_warn(&self, file: &str, line: u32, msg: String) {
|
||||
self.emit_event(EventType::Warning(format!("{file}:{line}: {msg}")));
|
||||
}
|
||||
|
||||
pub(crate) fn log_error(&self, msg: String) {
|
||||
self.set_last_error(&msg);
|
||||
self.emit_event(EventType::Error(msg));
|
||||
}
|
||||
|
||||
/// Set last error string.
|
||||
/// Implemented as blocking as used from macros in different, not always async blocks.
|
||||
pub fn set_last_error(&self, error: &str) {
|
||||
@@ -115,6 +116,12 @@ impl<T, E: std::fmt::Display> LogExt<T, E> for Result<T, E> {
|
||||
);
|
||||
// We can't use the warn!() macro here as the file!() and line!() macros
|
||||
// don't work with #[track_caller]
|
||||
tracing::event!(
|
||||
::tracing::Level::WARN,
|
||||
account_id = context.get_id(),
|
||||
"{}",
|
||||
&full
|
||||
);
|
||||
context.emit_event(crate::EventType::Warning(full));
|
||||
};
|
||||
self
|
||||
|
||||
@@ -93,6 +93,11 @@ impl<S: SessionStream> AsyncRead for LoggingStream<S> {
|
||||
"Read error on stream {peer_addr:?} after reading {} and writing {} bytes: {err}.",
|
||||
this.metrics.total_read, this.metrics.total_written
|
||||
);
|
||||
tracing::event!(
|
||||
::tracing::Level::WARN,
|
||||
account_id = *this.account_id,
|
||||
log_message
|
||||
);
|
||||
this.events.emit(Event {
|
||||
id: *this.account_id,
|
||||
typ: EventType::Warning(log_message),
|
||||
|
||||
+2
-4
@@ -320,10 +320,9 @@ pub(crate) fn render_queued_mail(
|
||||
outer_headers.extend(b"MIME-Version: 1.0\r\n");
|
||||
|
||||
if should_attach_pubkey {
|
||||
let public_key = crate::pgp::minimize_autocrypt_certificate(public_key);
|
||||
let aheader = Aheader {
|
||||
addr: from_addr,
|
||||
public_key,
|
||||
public_key: public_key.clone(),
|
||||
prefer_encrypt: EncryptPreference::Mutual,
|
||||
};
|
||||
let autocrypt_header = mail_builder::headers::raw::Raw::new(aheader.to_string());
|
||||
@@ -1360,10 +1359,9 @@ impl MimeFactory {
|
||||
continue;
|
||||
}
|
||||
|
||||
let public_key = crate::pgp::minimize_autocrypt_certificate(key);
|
||||
let header = Aheader {
|
||||
addr: addr.clone(),
|
||||
public_key,
|
||||
public_key: key.clone(),
|
||||
// Autocrypt 1.1.0 specification says that
|
||||
// `prefer-encrypt` attribute SHOULD NOT be included.
|
||||
prefer_encrypt: EncryptPreference::NoPreference,
|
||||
|
||||
@@ -366,6 +366,7 @@ async fn test_mdn_sent_to_all_relays() -> Result<()> {
|
||||
// Bob's key gets a second relay address and Alice merges the newer key.
|
||||
let bob_secret_key = load_self_secret_key(bob).await?;
|
||||
let bob_public_key = secret_key_to_public_key(
|
||||
bob,
|
||||
bob_secret_key,
|
||||
u32::try_from(time())? + 100,
|
||||
"bob@example.net",
|
||||
|
||||
+10
-270
@@ -1,8 +1,6 @@
|
||||
//! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp).
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::btree_map::Entry as BTreeMapEntry;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Cursor;
|
||||
|
||||
use anyhow::{Context as _, Result, ensure};
|
||||
@@ -16,7 +14,7 @@ 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::{Signature, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::packet::{Signature, Subpacket, SubpacketData};
|
||||
use pgp::types::{
|
||||
CompressionAlgorithm, Imprint, KeyDetails, KeyVersion, Password, SignedUser, SigningKey as _,
|
||||
StringToKey,
|
||||
@@ -27,8 +25,6 @@ use sha2::Sha256;
|
||||
use crate::configure::MAX_RELAYS;
|
||||
use crate::key::{DcKey, Fingerprint};
|
||||
|
||||
pub(crate) mod autocrypt2;
|
||||
|
||||
/// Preferred symmetric encryption algorithm.
|
||||
const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128;
|
||||
|
||||
@@ -87,64 +83,13 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<SignedSecretKey> {
|
||||
|
||||
/// Selects a subkey of the public key to use for encryption.
|
||||
///
|
||||
/// The key is selected according to
|
||||
/// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-03.html#section-4.3-4>.
|
||||
/// If multiple keys are available, the one that will expire sooner is selected.
|
||||
///
|
||||
/// Returns `None` if the public key cannot be used for encryption.
|
||||
fn select_pk_for_encryption(now: u32, key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
|
||||
///
|
||||
/// TODO: take key flags and expiration dates into account
|
||||
fn select_pk_for_encryption(key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
|
||||
key.public_subkeys
|
||||
.iter()
|
||||
.filter(|subkey| subkey.algorithm().can_encrypt())
|
||||
.filter_map(|subkey| {
|
||||
// We take arbitrary signature here and don't try to select the newest one.
|
||||
// Filtering out outdated and invalid signatures should happen during certificate merging.
|
||||
let signature = subkey.signatures.first()?;
|
||||
|
||||
let key_flags = signature.key_flags();
|
||||
if !key_flags.encrypt_comms() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(expiration_duration) = signature
|
||||
.key_expiration_time()
|
||||
.filter(|duration| duration.as_secs() != 0)
|
||||
&& now
|
||||
> subkey
|
||||
.created_at()
|
||||
.as_secs()
|
||||
.saturating_add(expiration_duration.as_secs())
|
||||
{
|
||||
// Key is expired.
|
||||
return None;
|
||||
}
|
||||
Some((subkey, signature))
|
||||
})
|
||||
.min_by(|(subkey1, signature1), (subkey2, signature2)| {
|
||||
match (
|
||||
signature1
|
||||
.key_expiration_time()
|
||||
.filter(|duration| duration.as_secs() != 0),
|
||||
signature2
|
||||
.key_expiration_time()
|
||||
.filter(|duration| duration.as_secs() != 0),
|
||||
) {
|
||||
(None, None) => Ordering::Equal,
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(Some(expiration1), Some(expiration2)) => (subkey1
|
||||
.created_at()
|
||||
.as_secs()
|
||||
.saturating_add(expiration1.as_secs()))
|
||||
.cmp(
|
||||
&(subkey2
|
||||
.created_at()
|
||||
.as_secs()
|
||||
.saturating_add(expiration2.as_secs())),
|
||||
),
|
||||
}
|
||||
})
|
||||
.map(|(subkey, _signature)| subkey)
|
||||
.find(|subkey| subkey.algorithm().can_encrypt())
|
||||
}
|
||||
|
||||
/// Version of SEIPD packet to use.
|
||||
@@ -206,11 +151,10 @@ pub fn pk_encrypt(
|
||||
) -> Result<String> {
|
||||
tokio::task::block_in_place(|| {
|
||||
let mut rng = thread_rng();
|
||||
let now = pgp::types::Timestamp::now();
|
||||
|
||||
let pkeys = public_keys_for_encryption
|
||||
.iter()
|
||||
.filter_map(|key| select_pk_for_encryption(now.as_secs(), key));
|
||||
.filter_map(select_pk_for_encryption);
|
||||
|
||||
let msg = MessageBuilder::from_bytes("", plain);
|
||||
let encoded_msg = match seipd_version {
|
||||
@@ -366,161 +310,6 @@ pub fn symm_encrypt_message(
|
||||
})
|
||||
}
|
||||
|
||||
/// Minimizes the signatures of a subkey.
|
||||
///
|
||||
/// Keeps at most one subkey binding signature
|
||||
/// and at most one revocation signature,
|
||||
/// preferring the newest signatures.
|
||||
///
|
||||
/// Subkey binding signature is kept
|
||||
/// even if the revocation signature exists
|
||||
/// because according to
|
||||
/// <https://www.rfc-editor.org/rfc/rfc9580.html#name-openpgp-version-6-certifica>
|
||||
/// "Every subkey MUST have at least one Subkey Binding signature."
|
||||
/// Distributing subkey with only a revocation signature
|
||||
/// is not allowed according to the standard,
|
||||
/// so we keep a subkey binding signature next to it
|
||||
/// for interoperability.
|
||||
///
|
||||
/// This function does not check if the signatures are valid.
|
||||
/// Such properties should be validated when importing OpenPGP certificates.
|
||||
fn minimize_subpacket_signatures(signatures: Vec<Signature>) -> Vec<Signature> {
|
||||
let mut newest_revocation_signature: Option<Signature> = None;
|
||||
let mut newest_binding_signature: Option<Signature> = None;
|
||||
for signature in signatures {
|
||||
let Some(config) = signature.config() else {
|
||||
// Skip unknown signatures.
|
||||
continue;
|
||||
};
|
||||
match config.typ {
|
||||
SignatureType::SubkeyBinding => {
|
||||
if newest_binding_signature
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.created() < signature.created())
|
||||
{
|
||||
newest_binding_signature = Some(signature)
|
||||
}
|
||||
}
|
||||
SignatureType::SubkeyRevocation => {
|
||||
if newest_revocation_signature
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.created() < signature.created())
|
||||
{
|
||||
newest_revocation_signature = Some(signature)
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
newest_revocation_signature
|
||||
.into_iter()
|
||||
.chain(newest_binding_signature)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Minimizes OpenPGP certificate for Autocrypt and Autocrypt-Gossip headers.
|
||||
pub fn minimize_autocrypt_certificate(certificate: &SignedPublicKey) -> SignedPublicKey {
|
||||
let primary_key = certificate.primary_key.clone();
|
||||
let details = certificate.details.clone();
|
||||
|
||||
// Select the newest non-expiring subkey and the newest expiring subkey.
|
||||
let fallback_subkey = certificate
|
||||
.public_subkeys
|
||||
.iter()
|
||||
.filter(|subkey| {
|
||||
subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.find(|signature| {
|
||||
signature
|
||||
.config()
|
||||
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
|
||||
})
|
||||
.is_some_and(|signature| signature.key_expiration_time().is_none())
|
||||
})
|
||||
.max_by_key(|subkey| {
|
||||
subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.find(|signature| {
|
||||
signature
|
||||
.config()
|
||||
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
|
||||
})
|
||||
.map(|signature| signature.created().unwrap_or(subkey.created_at()))
|
||||
});
|
||||
let rotating_subkey = certificate
|
||||
.public_subkeys
|
||||
.iter()
|
||||
.filter(|subkey| {
|
||||
subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.find(|signature| {
|
||||
signature
|
||||
.config()
|
||||
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
|
||||
})
|
||||
.is_some_and(|signature| signature.key_expiration_time().is_some())
|
||||
})
|
||||
.max_by_key(|subkey| {
|
||||
subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.find(|signature| {
|
||||
signature
|
||||
.config()
|
||||
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
|
||||
})
|
||||
.map(|signature| signature.created().unwrap_or(subkey.created_at()))
|
||||
});
|
||||
let public_subkeys: Vec<_> = fallback_subkey
|
||||
.into_iter()
|
||||
.chain(rotating_subkey)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// We do not want to ever gossip more than two subkeys
|
||||
// to save the traffic.
|
||||
debug_assert!(public_subkeys.len() <= 2);
|
||||
|
||||
SignedPublicKey {
|
||||
primary_key,
|
||||
details,
|
||||
public_subkeys,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges two OpenPGP subkeys.
|
||||
fn merge_openpgp_subkey(old_subkey: &mut SignedPublicSubKey, new_subkey: SignedPublicSubKey) {
|
||||
debug_assert_eq!(old_subkey.fingerprint(), new_subkey.fingerprint());
|
||||
old_subkey.signatures = minimize_subpacket_signatures(
|
||||
std::mem::take(&mut old_subkey.signatures)
|
||||
.into_iter()
|
||||
.chain(new_subkey.signatures)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Merges OpenPGP subkey vectors.
|
||||
pub fn merge_openpgp_subkeys(
|
||||
subkeys: impl IntoIterator<Item = SignedPublicSubKey>,
|
||||
) -> Result<Vec<SignedPublicSubKey>> {
|
||||
let mut merged_subkeys: BTreeMap<_, SignedPublicSubKey> = BTreeMap::new();
|
||||
for subkey in subkeys {
|
||||
let imprint = subkey.imprint::<Sha256>()?;
|
||||
match merged_subkeys.entry(imprint) {
|
||||
BTreeMapEntry::Vacant(entry) => {
|
||||
entry.insert(subkey);
|
||||
}
|
||||
BTreeMapEntry::Occupied(entry) => {
|
||||
merge_openpgp_subkey(entry.into_mut(), subkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(merged_subkeys.into_values().collect())
|
||||
}
|
||||
|
||||
/// Merges and minimizes OpenPGP certificates.
|
||||
///
|
||||
/// Keeps at most one direct key signature and
|
||||
@@ -557,7 +346,7 @@ pub fn merge_openpgp_certificates(
|
||||
let SignedPublicKey {
|
||||
primary_key: new_primary_key,
|
||||
details: new_details,
|
||||
public_subkeys: new_public_subkeys,
|
||||
public_subkeys: _new_public_subkeys,
|
||||
} = new_certificate;
|
||||
|
||||
// Public keys may be serialized differently, e.g. using old and new packet type,
|
||||
@@ -633,55 +422,7 @@ pub fn merge_openpgp_certificates(
|
||||
});
|
||||
let users: Vec<SignedUser> = best_user.into_iter().collect();
|
||||
|
||||
let (fallback_subkeys, mut rotating_subkeys): (Vec<_>, Vec<_>) =
|
||||
merge_openpgp_subkeys(old_public_subkeys.into_iter().chain(new_public_subkeys))?
|
||||
.into_iter()
|
||||
.filter_map(|subkey| {
|
||||
// Select the newest subkey binding signature.
|
||||
//
|
||||
// There is at most one subkey binding signature at this point
|
||||
// because older subkey binding signatures are removed during merging.
|
||||
let signature = subkey.signatures.iter().find(|signature| {
|
||||
signature
|
||||
.config()
|
||||
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
|
||||
})?;
|
||||
|
||||
let created_at_secs = signature.created().unwrap_or(subkey.created_at()).as_secs();
|
||||
let expires_at_secs: Option<u32> = signature
|
||||
.key_expiration_time()
|
||||
.map(|duration| duration.as_secs())
|
||||
.filter(|duration_secs| *duration_secs != 0)
|
||||
.map(|duration_secs| {
|
||||
subkey.created_at().as_secs().saturating_add(duration_secs)
|
||||
});
|
||||
|
||||
Some((subkey, created_at_secs, expires_at_secs))
|
||||
})
|
||||
.partition(|(_subkey, _created_at_secs, expires_at_secs)| expires_at_secs.is_none());
|
||||
let fallback_subkey: Option<SignedPublicSubKey> = fallback_subkeys
|
||||
.into_iter()
|
||||
.max_by_key(|(_subkey, created_at_secs, _)| *created_at_secs)
|
||||
.map(|(subkey, _, _)| subkey);
|
||||
|
||||
rotating_subkeys
|
||||
.sort_by_key(|(_subkey, created_at_secs, _)| std::cmp::Reverse(*created_at_secs));
|
||||
|
||||
// Put the fallback subkey first so it is gossiped first.
|
||||
//
|
||||
// We want to always gossip non-expiring key first
|
||||
// for older versions that always encrypted to the first subkey.
|
||||
//
|
||||
// Keep 10 newest rotating subkeys to avoid storing indefinitely growing number of subkeys locally.
|
||||
let public_subkeys = fallback_subkey
|
||||
.into_iter()
|
||||
.chain(
|
||||
rotating_subkeys
|
||||
.into_iter()
|
||||
.take(10)
|
||||
.map(|(subkey, _, _)| subkey),
|
||||
)
|
||||
.collect();
|
||||
let public_subkeys = old_public_subkeys;
|
||||
|
||||
Ok(SignedPublicKey {
|
||||
primary_key: old_primary_key,
|
||||
@@ -744,8 +485,7 @@ pub(crate) fn relay_addrs(public_key: &SignedPublicKey, addr: &str) -> Vec<Strin
|
||||
|
||||
/// Returns true if the key can be encrypted to, i.e. has an encryption subkey.
|
||||
pub(crate) fn pubkey_can_encrypt(public_key: &SignedPublicKey) -> bool {
|
||||
let now = pgp::types::Timestamp::now();
|
||||
select_pk_for_encryption(now.as_secs(), public_key).is_some()
|
||||
select_pk_for_encryption(public_key).is_some()
|
||||
}
|
||||
|
||||
/// Returns true if public key advertises SEIPDv2 feature.
|
||||
|
||||
@@ -1,588 +0,0 @@
|
||||
//! Autocrypt2 implementation.
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use anyhow::ensure;
|
||||
use anyhow::format_err;
|
||||
use hkdf::Hkdf;
|
||||
use pgp::composed::SignedKeyDetails;
|
||||
use pgp::composed::SignedSecretKey;
|
||||
use pgp::composed::SignedSecretSubKey;
|
||||
use pgp::crypto::aead::AeadAlgorithm;
|
||||
use pgp::crypto::ed25519;
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::crypto::ml_kem768_x25519;
|
||||
use pgp::crypto::public_key::PublicKeyAlgorithm;
|
||||
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||
use pgp::packet::Features;
|
||||
use pgp::packet::KeyFlags;
|
||||
use pgp::packet::PacketTrait as _;
|
||||
use pgp::packet::PubKeyInner;
|
||||
use pgp::packet::PublicKey;
|
||||
use pgp::packet::PublicSubkey;
|
||||
use pgp::packet::SecretKey;
|
||||
use pgp::packet::SecretSubkey;
|
||||
use pgp::packet::SignatureConfig;
|
||||
use pgp::packet::SignatureType;
|
||||
use pgp::packet::Subpacket;
|
||||
use pgp::packet::SubpacketData;
|
||||
use pgp::ser::Serialize as _;
|
||||
use pgp::types::Duration as PgpDuration;
|
||||
use pgp::types::Ed25519PublicParams;
|
||||
use pgp::types::KeyDetails;
|
||||
use pgp::types::KeyVersion;
|
||||
use pgp::types::MlKem768X25519PublicParams;
|
||||
use pgp::types::Password;
|
||||
use pgp::types::PlainSecretParams;
|
||||
use pgp::types::PublicParams;
|
||||
use pgp::types::SecretParams;
|
||||
use pgp::types::Timestamp;
|
||||
use rand_old::thread_rng;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha512;
|
||||
|
||||
/// Creates an Autocrypt 2 TSK.
|
||||
///
|
||||
/// <https://datatracker.ietf.org/doc/draft-autocrypt-openpgp-v2-cert/>
|
||||
pub(crate) fn create_autocrypt2_keypair(now: Timestamp) -> Result<SignedSecretKey> {
|
||||
let mut rng = thread_rng();
|
||||
|
||||
// Fake zero timestamp for primary key and fallback key creation.
|
||||
// We do not want to leak the key creation date to contacts.
|
||||
// This is not to be used for rotating subkey timestamps.
|
||||
let zero_timestamp = Timestamp::from_secs(0);
|
||||
|
||||
let public_key_algorithm = PublicKeyAlgorithm::Ed25519;
|
||||
|
||||
let primary_key_packet = {
|
||||
let ed25519_secret = ed25519::SecretKey::generate(&mut rng, ed25519::Mode::Ed25519);
|
||||
let public_params = PublicParams::Ed25519(Ed25519PublicParams::from(&ed25519_secret));
|
||||
let secret_params = SecretParams::Plain(PlainSecretParams::Ed25519(ed25519_secret));
|
||||
|
||||
let pubkey_inner = PubKeyInner::new(
|
||||
KeyVersion::V6,
|
||||
public_key_algorithm,
|
||||
zero_timestamp,
|
||||
None,
|
||||
public_params,
|
||||
)?;
|
||||
let pubkey = PublicKey::from_inner(pubkey_inner)?;
|
||||
SecretKey::new(pubkey, secret_params)?
|
||||
};
|
||||
|
||||
let details = {
|
||||
let mut signature_config =
|
||||
SignatureConfig::from_key(&mut rng, &primary_key_packet, SignatureType::Key)?;
|
||||
let mut keyflags = KeyFlags::default();
|
||||
keyflags.set_certify(true);
|
||||
keyflags.set_sign(true);
|
||||
|
||||
let mut features = Features::default();
|
||||
features.set_seipd_v1(true);
|
||||
features.set_seipd_v2(true);
|
||||
|
||||
signature_config.hashed_subpackets = vec![
|
||||
Subpacket::critical(SubpacketData::SignatureCreationTime(now))?,
|
||||
Subpacket::regular(SubpacketData::KeyFlags(keyflags))?,
|
||||
Subpacket::regular(SubpacketData::Features(features))?,
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(
|
||||
primary_key_packet.fingerprint(),
|
||||
))?,
|
||||
Subpacket::regular(SubpacketData::PreferredAeadAlgorithms(smallvec![(
|
||||
SymmetricKeyAlgorithm::AES256,
|
||||
AeadAlgorithm::Ocb
|
||||
)]))?,
|
||||
];
|
||||
|
||||
let signature = signature_config.sign_key(
|
||||
&primary_key_packet,
|
||||
&Password::empty(),
|
||||
&primary_key_packet.public_key(),
|
||||
)?;
|
||||
|
||||
SignedKeyDetails {
|
||||
revocation_signatures: vec![],
|
||||
direct_signatures: vec![signature],
|
||||
users: vec![],
|
||||
user_attributes: vec![],
|
||||
}
|
||||
};
|
||||
|
||||
let fallback_subkey_packet = {
|
||||
let ml_kem_secret = ml_kem768_x25519::SecretKey::generate(&mut rng);
|
||||
let public_params =
|
||||
PublicParams::MlKem768X25519(MlKem768X25519PublicParams::from(&ml_kem_secret));
|
||||
let secret_params = SecretParams::Plain(PlainSecretParams::MlKem768X25519(ml_kem_secret));
|
||||
|
||||
let pubkey_inner = PubKeyInner::new(
|
||||
KeyVersion::V6,
|
||||
PublicKeyAlgorithm::MlKem768X25519,
|
||||
zero_timestamp,
|
||||
None,
|
||||
public_params,
|
||||
)?;
|
||||
let public_subkey = PublicSubkey::from_inner(pubkey_inner)?;
|
||||
SecretSubkey::new(public_subkey, secret_params)?
|
||||
};
|
||||
|
||||
let signed_fallback_subkey = {
|
||||
let mut keyflags = KeyFlags::default();
|
||||
keyflags.set_encrypt_storage(true);
|
||||
keyflags.set_encrypt_comms(true);
|
||||
|
||||
let mut signature_config = SignatureConfig::v6(
|
||||
&mut rng,
|
||||
SignatureType::SubkeyBinding,
|
||||
public_key_algorithm,
|
||||
HashAlgorithm::Sha256,
|
||||
)?;
|
||||
signature_config.hashed_subpackets = vec![
|
||||
Subpacket::critical(SubpacketData::SignatureCreationTime(zero_timestamp))?,
|
||||
Subpacket::critical(SubpacketData::KeyFlags(keyflags))?,
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(
|
||||
primary_key_packet.fingerprint(),
|
||||
))?,
|
||||
];
|
||||
let signature = signature_config.sign_subkey_binding(
|
||||
&primary_key_packet,
|
||||
primary_key_packet.public_key(),
|
||||
&Password::empty(),
|
||||
fallback_subkey_packet.public_key(),
|
||||
)?;
|
||||
SignedSecretSubKey {
|
||||
key: fallback_subkey_packet,
|
||||
signatures: vec![signature],
|
||||
}
|
||||
};
|
||||
|
||||
let rotating_subkey_packet = {
|
||||
let ml_kem_secret = ml_kem768_x25519::SecretKey::generate(&mut rng);
|
||||
let public_params =
|
||||
PublicParams::MlKem768X25519(MlKem768X25519PublicParams::from(&ml_kem_secret));
|
||||
let secret_params = SecretParams::Plain(PlainSecretParams::MlKem768X25519(ml_kem_secret));
|
||||
|
||||
let mut keyflags = KeyFlags::default();
|
||||
keyflags.set_encrypt_comms(true);
|
||||
|
||||
let pubkey_inner = PubKeyInner::new(
|
||||
KeyVersion::V6,
|
||||
PublicKeyAlgorithm::MlKem768X25519,
|
||||
now,
|
||||
None,
|
||||
public_params,
|
||||
)?;
|
||||
let public_subkey = PublicSubkey::from_inner(pubkey_inner)?;
|
||||
SecretSubkey::new(public_subkey, secret_params)?
|
||||
};
|
||||
|
||||
let signed_rotating_subkey = {
|
||||
let mut keyflags = KeyFlags::default();
|
||||
keyflags.set_encrypt_comms(true);
|
||||
|
||||
let mut signature_config = SignatureConfig::v6(
|
||||
&mut rng,
|
||||
SignatureType::SubkeyBinding,
|
||||
public_key_algorithm,
|
||||
HashAlgorithm::Sha256,
|
||||
)?;
|
||||
|
||||
// Expiration duration is 10 days according to
|
||||
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-2.2-2.6.2.2.1>
|
||||
let expiration_duration = PgpDuration::from_secs(864000);
|
||||
signature_config.hashed_subpackets = vec![
|
||||
Subpacket::critical(SubpacketData::SignatureCreationTime(now))?,
|
||||
Subpacket::critical(SubpacketData::KeyFlags(keyflags))?,
|
||||
// XXX: marking expiration as critical
|
||||
// even though reference implementation does not:
|
||||
// <https://codeberg.org/autocrypt2/autocrypt-v2-cert/issues/53>
|
||||
Subpacket::critical(SubpacketData::KeyExpirationTime(expiration_duration))?,
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(
|
||||
primary_key_packet.fingerprint(),
|
||||
))?,
|
||||
];
|
||||
let signature = signature_config.sign_subkey_binding(
|
||||
&primary_key_packet,
|
||||
primary_key_packet.public_key(),
|
||||
&Password::empty(),
|
||||
rotating_subkey_packet.public_key(),
|
||||
)?;
|
||||
SignedSecretSubKey {
|
||||
key: rotating_subkey_packet,
|
||||
signatures: vec![signature],
|
||||
}
|
||||
};
|
||||
|
||||
let secret_key = SignedSecretKey {
|
||||
primary_key: primary_key_packet,
|
||||
details,
|
||||
public_subkeys: Vec::new(),
|
||||
secret_subkeys: vec![signed_fallback_subkey, signed_rotating_subkey],
|
||||
};
|
||||
|
||||
secret_key
|
||||
.verify_bindings()
|
||||
.context("Invalid Autocrypt2 key generated")?;
|
||||
|
||||
Ok(secret_key)
|
||||
}
|
||||
|
||||
/// Returns true if TSK is an Autocrypt 2 TSK.
|
||||
///
|
||||
/// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#name-identification-by-tsk-struc>
|
||||
fn is_autocrypt2_tsk(tsk: &SignedSecretKey) -> bool {
|
||||
if tsk.primary_key.version() != KeyVersion::V6
|
||||
|| tsk.primary_key.algorithm() != PublicKeyAlgorithm::Ed25519
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Direct key signature.
|
||||
let [direct_key_signature] = &tsk.details.direct_signatures[..] else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(features) = direct_key_signature.features() else {
|
||||
return false;
|
||||
};
|
||||
// SEIPDv2 feature is required according to
|
||||
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-2.2-2.2.2.4.1>
|
||||
if !features.seipd_v2() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Primary key must have certification (0x01) and signing (0x02) flags.
|
||||
let dks_key_flags = direct_key_signature.key_flags();
|
||||
if !dks_key_flags.certify() || !dks_key_flags.sign() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No expiration:
|
||||
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-2.2-2.2.2.6.1>
|
||||
// No key expiration (<https://www.rfc-editor.org/rfc/rfc9580.html#name-key-expiration-time>)
|
||||
// and no signature expiration (<https://docs.rs/pgp/latest/pgp/packet/struct.Signature.html#method.signature_expiration_time>).
|
||||
//
|
||||
// XXX: spec should say explicitly that both key expiration and signature expiration should not be there
|
||||
if direct_key_signature
|
||||
.key_expiration_time()
|
||||
.is_some_and(|duration| duration.as_secs() != 0)
|
||||
|| direct_key_signature
|
||||
.signature_expiration_time()
|
||||
.is_some_and(|duration| duration.as_secs() != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if !(tsk.details.revocation_signatures.is_empty()
|
||||
&& tsk.details.users.is_empty()
|
||||
&& tsk.details.user_attributes.is_empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if !tsk.public_subkeys.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: check all rotating subkeys
|
||||
// Subkeys may overlap, as long as subkey is not expired, it does not need to be deleted.
|
||||
let [ref fallback_subkey, .., ref rotating_subkey] = tsk.secret_subkeys[..] else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let [ref fallback_subkey_signature] = fallback_subkey.signatures[..] else {
|
||||
return false;
|
||||
};
|
||||
let fallback_subkey_flags = fallback_subkey_signature.key_flags();
|
||||
if !fallback_subkey_flags.encrypt_comms() || !fallback_subkey_flags.encrypt_storage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if fallback_subkey_signature
|
||||
.key_expiration_time()
|
||||
.is_some_and(|duration| duration.as_secs() != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let [ref rotating_subkey_signature] = rotating_subkey.signatures[..] else {
|
||||
return false;
|
||||
};
|
||||
let rotating_subkey_flags = rotating_subkey_signature.key_flags();
|
||||
// Rotating subkey can be used to encrypt communications, but not storage:
|
||||
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-2.2-2.6.2.3.1>
|
||||
if !rotating_subkey_flags.encrypt_comms() || rotating_subkey_flags.encrypt_storage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if rotating_subkey_signature
|
||||
.key_expiration_time()
|
||||
.is_none_or(|duration| duration.as_secs() == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn normalize_x25519_scalar(m: &mut [u8]) {
|
||||
// From decodeScalar25519 in <https://www.rfc-editor.org/info/rfc7748/#section-5>
|
||||
m[0] &= 248;
|
||||
m[31] &= 127;
|
||||
m[31] |= 64;
|
||||
}
|
||||
|
||||
/// Generates new rotating subkey from a previous one.
|
||||
///
|
||||
/// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-4.1.1>
|
||||
fn ratchet(mut tsk: SignedSecretKey) -> Result<SignedSecretKey> {
|
||||
// Extract the last rotating subkey.
|
||||
// Other rotating subkeys do not matter.
|
||||
// This corresponds to
|
||||
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-4.1.1-6.2.1>
|
||||
let [ref _fallback_subkey, .., ref rotating_subkey] = tsk.secret_subkeys[..] else {
|
||||
bail!("Cannot extract last rotating subkey");
|
||||
};
|
||||
let [ref rotating_subkey_signature] = rotating_subkey.signatures[..] else {
|
||||
bail!("Rotating subkey must have exactly one signature");
|
||||
};
|
||||
let rotating_subkey_flags = rotating_subkey_signature.key_flags();
|
||||
// We do not search for the latest-expiring subkey
|
||||
// with the ability to encrypt communications.
|
||||
// It must be the last one by convention.
|
||||
// TODO: write TSK structure explicitly in the specification.
|
||||
let max_rd: u32 = rotating_subkey_signature
|
||||
.key_expiration_time()
|
||||
.context("Last subkey is not expiring")?
|
||||
.as_secs();
|
||||
let min_rd: u32 = max_rd / 2;
|
||||
ensure!(
|
||||
rotating_subkey_flags.encrypt_comms(),
|
||||
"Last rotating subkey cannot be used to encrypt communications"
|
||||
);
|
||||
|
||||
let start: u32 = rotating_subkey
|
||||
.created_at()
|
||||
.as_secs()
|
||||
.checked_add(min_rd)
|
||||
.context("Overflow while adding min_rd")?;
|
||||
let mut salt = Vec::from(start.to_be_bytes());
|
||||
rotating_subkey
|
||||
.public_key()
|
||||
.to_writer_with_header(&mut salt)
|
||||
.context("Failed to serialize rotating subkey")?;
|
||||
debug_assert_eq!(
|
||||
salt.len(),
|
||||
4 + rotating_subkey.public_key().write_len_with_header()
|
||||
);
|
||||
|
||||
let SecretParams::Plain(PlainSecretParams::MlKem768X25519(old_ml_kem768_x25519_secret_key)) =
|
||||
rotating_subkey.secret_params()
|
||||
else {
|
||||
bail!("Cannot extract ML-KEM-768 + X25519 secret key");
|
||||
};
|
||||
let mut ikm = Vec::with_capacity(old_ml_kem768_x25519_secret_key.write_len());
|
||||
old_ml_kem768_x25519_secret_key
|
||||
.to_writer(&mut ikm)
|
||||
.context("Failed to serialize IKM")?;
|
||||
|
||||
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-4.1.1-6.6.1>
|
||||
normalize_x25519_scalar(&mut ikm);
|
||||
debug_assert_eq!(ikm.len(), 96);
|
||||
|
||||
let info = {
|
||||
let mut info = b"Autocrypt_v2_ratchet".to_vec();
|
||||
tsk.primary_key
|
||||
.public_key()
|
||||
.to_writer_with_header(&mut info)
|
||||
.context("Failed to serialize primary key")?;
|
||||
info.extend_from_slice(&max_rd.to_be_bytes());
|
||||
info
|
||||
};
|
||||
|
||||
let hkdf = Hkdf::<Sha512>::new(Some(&salt), &ikm);
|
||||
let mut ks = [0u8; 160];
|
||||
hkdf.expand(&info, &mut ks)
|
||||
.map_err(|_err: hkdf::InvalidLength| {
|
||||
format_err!("HKDF-Expand failed because of invalid output length")
|
||||
})?;
|
||||
|
||||
let new_ml_kem768_x25519_secret_key = {
|
||||
let mut new_x25519 = [0u8; 32];
|
||||
let mut new_ml_kem = [0u8; 64];
|
||||
new_x25519.copy_from_slice(&ks[64..96]);
|
||||
new_ml_kem.copy_from_slice(&ks[96..160]);
|
||||
normalize_x25519_scalar(&mut new_x25519[..]);
|
||||
|
||||
ml_kem768_x25519::SecretKey::try_from_bytes(new_x25519, new_ml_kem)?
|
||||
};
|
||||
let new_rotating_subkey = {
|
||||
let public_params = PublicParams::MlKem768X25519(MlKem768X25519PublicParams::from(
|
||||
&new_ml_kem768_x25519_secret_key,
|
||||
));
|
||||
let secret_params = SecretParams::Plain(PlainSecretParams::MlKem768X25519(
|
||||
new_ml_kem768_x25519_secret_key,
|
||||
));
|
||||
|
||||
let pubkey_inner = PubKeyInner::new(
|
||||
KeyVersion::V6,
|
||||
PublicKeyAlgorithm::MlKem768X25519,
|
||||
Timestamp::from_secs(start),
|
||||
None,
|
||||
public_params,
|
||||
)?;
|
||||
let public_subkey = PublicSubkey::from_inner(pubkey_inner)?;
|
||||
SecretSubkey::new(public_subkey, secret_params)?
|
||||
};
|
||||
|
||||
let new_signed_rotating_subkey = {
|
||||
let mut keyflags = KeyFlags::default();
|
||||
keyflags.set_encrypt_comms(true);
|
||||
|
||||
let digest = Sha512::digest(&ks[0..64]);
|
||||
let bssalt = digest[0..16].to_vec();
|
||||
|
||||
let mut signature_config = SignatureConfig::v6_with_salt(
|
||||
SignatureType::SubkeyBinding,
|
||||
tsk.primary_key.algorithm(),
|
||||
HashAlgorithm::Sha256,
|
||||
bssalt,
|
||||
);
|
||||
|
||||
// FIXME
|
||||
let expiration_duration = PgpDuration::from_secs(864000);
|
||||
signature_config.hashed_subpackets = vec![
|
||||
Subpacket::critical(SubpacketData::SignatureCreationTime(Timestamp::from_secs(
|
||||
start,
|
||||
)))?,
|
||||
Subpacket::critical(SubpacketData::KeyFlags(keyflags))?,
|
||||
// XXX: marking expiration as critical
|
||||
// even though reference implementation does not:
|
||||
// <https://codeberg.org/autocrypt2/autocrypt-v2-cert/issues/53>
|
||||
Subpacket::critical(SubpacketData::KeyExpirationTime(expiration_duration))?,
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(
|
||||
tsk.primary_key.public_key().fingerprint(),
|
||||
))?,
|
||||
];
|
||||
let signature = signature_config.sign_subkey_binding(
|
||||
&tsk.primary_key,
|
||||
tsk.primary_key.public_key(),
|
||||
&Password::empty(),
|
||||
new_rotating_subkey.public_key(),
|
||||
)?;
|
||||
SignedSecretSubKey {
|
||||
key: new_rotating_subkey,
|
||||
signatures: vec![signature],
|
||||
}
|
||||
};
|
||||
|
||||
tsk.secret_subkeys.push(new_signed_rotating_subkey);
|
||||
Ok(tsk)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::key;
|
||||
use crate::pgp::DcKey;
|
||||
use crate::test_utils;
|
||||
|
||||
/// Tests creating Autocrypt 2 TSK and detecting it.
|
||||
#[test]
|
||||
fn test_create_autocrypt2_keypair() {
|
||||
let now = Timestamp::now();
|
||||
let keypair = create_autocrypt2_keypair(now).unwrap();
|
||||
assert!(is_autocrypt2_tsk(&keypair));
|
||||
|
||||
// Test that Autocrypt 2 TSK can be serialized and deserialized.
|
||||
let secret_key_bytes = DcKey::to_bytes(&keypair);
|
||||
let signed_secret_key = SignedSecretKey::from_slice(&secret_key_bytes)
|
||||
.expect("Cannot deserialize Autocrypt2 TSK");
|
||||
|
||||
assert!(is_autocrypt2_tsk(&signed_secret_key));
|
||||
}
|
||||
|
||||
/// Tests that the key does not leak creation timestamp.
|
||||
#[test]
|
||||
fn test_tsk_timestamps() {
|
||||
let now = Timestamp::now();
|
||||
let tsk = create_autocrypt2_keypair(now).unwrap();
|
||||
|
||||
// Primary key creation timestamp is zero.
|
||||
assert_eq!(tsk.primary_key.created_at().as_secs(), 0);
|
||||
|
||||
// Primary key direct key signature timestamp is zero.
|
||||
let [ref direct_signature] = tsk.details.direct_signatures[..] else {
|
||||
panic!("Autocrypt 2 TSK must have exactly one direct key signature");
|
||||
};
|
||||
|
||||
// Direct key signature is a real key creation timestamp
|
||||
// and should not be zero.
|
||||
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-2.2-2.2.2.1.1>
|
||||
// This timestamp from TSK should not leak into the public key however
|
||||
// as we recreate the signature every time relay list is changed:
|
||||
let created_timestamp = direct_signature.created().unwrap();
|
||||
assert_ne!(created_timestamp.as_secs(), 0);
|
||||
|
||||
let fallback_subkey = tsk
|
||||
.secret_subkeys
|
||||
.first()
|
||||
.expect("Fallback subkey not found");
|
||||
|
||||
// Fallback subkey creation timestamp should be zero.
|
||||
// We will not be able to change this timestamp and it should not leak
|
||||
// the profile creation timestamp.
|
||||
assert_eq!(fallback_subkey.key.created_at().as_secs(), 0);
|
||||
|
||||
// Fallback subkey binding signature timestamp must match
|
||||
// the direct key signature timestamp.
|
||||
// TODO: it should be recreated each time Direct Key Signature is recreated.
|
||||
let [ref fallback_subkey_signature] = fallback_subkey.signatures[..] else {
|
||||
panic!("Fallback subkey does not have exactly one binding signature");
|
||||
};
|
||||
}
|
||||
|
||||
/// Tests that Autocrypt 2 TSK detection is not triggered for existing non-AC2 test keys.
|
||||
#[test]
|
||||
fn test_is_autocrypt2_tsk_no_false_positives() {
|
||||
assert!(!is_autocrypt2_tsk(&test_utils::alice_keypair()));
|
||||
assert!(!is_autocrypt2_tsk(&test_utils::bob_keypair()));
|
||||
assert!(!is_autocrypt2_tsk(&test_utils::charlie_keypair()));
|
||||
assert!(!is_autocrypt2_tsk(&test_utils::dom_keypair()));
|
||||
assert!(!is_autocrypt2_tsk(&test_utils::elena_keypair()));
|
||||
assert!(!is_autocrypt2_tsk(&test_utils::pqc_keypair()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ratchet() {
|
||||
let now = Timestamp::now();
|
||||
let tsk = create_autocrypt2_keypair(now).unwrap();
|
||||
assert!(is_autocrypt2_tsk(&tsk));
|
||||
|
||||
let new_tsk = ratchet(tsk).expect("Ratchet failed");
|
||||
assert!(is_autocrypt2_tsk(&new_tsk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autocrypt2_key_selection() {
|
||||
let now = Timestamp::now();
|
||||
let tsk = create_autocrypt2_keypair(now).unwrap();
|
||||
|
||||
let public_key = key::secret_key_to_public_key(
|
||||
tsk.clone(),
|
||||
now.as_secs(),
|
||||
"alice@example.org",
|
||||
"alice@example.org",
|
||||
)
|
||||
.expect("Failed to convert secret key to public key");
|
||||
|
||||
// For Autocrypt 2 certificate rotating key should be selected for encryption.
|
||||
let pk_for_encryption =
|
||||
crate::pgp::select_pk_for_encryption(now.as_secs(), &public_key).unwrap();
|
||||
let [ref pk_for_encryption_signature] = pk_for_encryption.signatures[..] else {
|
||||
panic!("Selected public key has multiple signatures");
|
||||
};
|
||||
let key_flags = pk_for_encryption_signature.key_flags();
|
||||
assert!(key_flags.encrypt_comms());
|
||||
assert!(!key_flags.encrypt_storage());
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ use crate::{
|
||||
test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair},
|
||||
token,
|
||||
};
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use pgp::composed::{Esk, Message};
|
||||
use pgp::packet::PublicKeyEncryptedSessionKey;
|
||||
|
||||
@@ -424,212 +423,3 @@ async fn test_securejoin_pqc_joiner() {
|
||||
|
||||
tcm.execute_securejoin(bob, pqc).await;
|
||||
}
|
||||
|
||||
/// Tests that public subkey selection for encryption respects key flags and prefers expring subkeys.
|
||||
///
|
||||
/// Non-encryption subkeys such as RSA subkey for authentication are ignored.
|
||||
#[test]
|
||||
fn test_select_pk_for_encryption() {
|
||||
// Public key generated with GnuPG 2.4.9 with the following subkeys:
|
||||
// 1. Auth-only RSA subkey (92E762B9084CA740).
|
||||
// 2. Expired Curve25519 encryption subkey with 1-day expiration (C8F382BD0F35C49E)
|
||||
// 3. Ed25519 signing subkey (F177AC3118F923CC).
|
||||
// 4. Curve25519 encryption subkey with fingerprint (36188C6FFC8E267B)
|
||||
// 5. Curve25519 encryption subkey with 1 year expiration, valid in the beginning of 2008, with key ID 9223FCEE7546CDE7
|
||||
// 6. Curve25519 encryption subkey with no expiration (FD2C0567967223D8).
|
||||
// Primary key is an Ed25519 not expiring key.
|
||||
// Key 4 is the one that should be selected.
|
||||
|
||||
// Subkey 4 fingerprint.
|
||||
let expected_fallback_fingerprint = "cdeb3ba3999bf7880f0ee1f536188c6ffc8e267b";
|
||||
|
||||
// Subkey 5 fingerprint, should be preferred to fallback when not expired.
|
||||
let expected_expiring_fingerprint = "5133fab157c4a46ca41f6dc39223fcee7546cde7";
|
||||
|
||||
let alice_tpk_asc = "
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMERvfcPBYJKwYBBAHaRw8BAQdAimvPsr7NdJ4dBoFPySwhpTQqoYOoHL3AzfE7
|
||||
mGWQOOC0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz6IkAQTFgoAOBYhBCi19Yqv
|
||||
ugVVkhyHgicqAms0FFoiBQJG99w8AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
|
||||
AAoJECcqAms0FFoiUGEA/3VZMBCoRq0ZpHarzmvzgdZCoL3r3m9en/eZScFzxITx
|
||||
AP42Mn27r0SOKwIln0VcPTdAQCk49mBW/EX3CMOlLPU3DLkBjQRG99w8AQwArsYe
|
||||
Jkdl6sSM/hfoEw0vgx/RdUBQ6QRYi1uc0UUNlIGy8mlczLFdkD3JF/hGocjPvt45
|
||||
XAQoK110zAkZlfpFRqNT1M/IC68Er8rLkYPC4OeFh6W4Iyn17fcUanP0lf8em/jh
|
||||
Vffvgy8sFOMdO235lvFA3txNA98s4fHdmU3PScyd1hc3C4M0yP83LnYyWt4X59Xc
|
||||
E/Om5Dm458eKCSeYkLI6752W0mXsBxSi3/dLn0XeuNRpgmKxSkm562FHOFaLbKtR
|
||||
Y5hobAI9PkNcVgRxZZvWQls5PHTZWjqngF21lKlaLfdqZ/Uae1i2hzZOihgitL43
|
||||
Le00qwNBi5hKYMeuDnHaQrLmb+A+0/IAEE4Ub+TkZhzI+2ZP2k1cAT0qZmZi0w+q
|
||||
xqP9INk0hY/oZFCnV2wkHN7zvQmVlUIcQ2rmfbafK1yiEL1qeGT96zyjbdXeGPqJ
|
||||
3O9h+YIG38JMRJBijsFujUN34Z546zS/kzOPXsz/WlGUMjwu8n5s5uf2TFyFABEB
|
||||
AAGIeAQYFgoAIBYhBCi19YqvugVVkhyHgicqAms0FFoiBQJG99w8AhsgAAoJECcq
|
||||
Ams0FFoiCOUBAPafRLDpWN9iT4hcCXjESf1Hw5KNVkJpwfzPfu2H9BkMAQCaHhKg
|
||||
pq9ywH4pyOHZCPV8P2ywkyn+EsjBC3fG+GBBBbg4BEb33DwSCisGAQQBl1UBBQEB
|
||||
B0DfI8AJFT3nWa6ZXLkHSf7W8W7S6AWIO7LAcjoyHwb8CwMBCAeIfgQYFgoAJhYh
|
||||
BCi19YqvugVVkhyHgicqAms0FFoiBQJG99w8AhsMBQkAAVGAAAoJECcqAms0FFoi
|
||||
U5EA/3G74HRwIMJlNOEW5gkYYV5KJW2qgtMfxHCUjoHvNWU1AQCHt/bLU2aviAiS
|
||||
of1R43qojxKUqzzoi8lYRQ+1sYhvB7gzBEb33DwWCSsGAQQB2kcPAQEHQKZXUJ7s
|
||||
xqH3kVcMnhasw6DrFMwCxHDdj+qvkg8r/DvtiO8EGBYKACAWIQQotfWKr7oFVZIc
|
||||
h4InKgJrNBRaIgUCRvfcPAIbAgCBCRAnKgJrNBRaInYgBBkWCgAdFiEEI8hstnVQ
|
||||
9sgylIYg8XesMRj5I8wFAkb33DwACgkQ8XesMRj5I8xo6QEAu4o/TyEZwFcyqZpw
|
||||
LEo9vTLCsc7fo0nx0ssiP6FyV5cBAOWal1DznDhsXWCNt+U8UaafXsU2DTV51KaD
|
||||
VBOVFo4CyeQBAMirIjXV5PbUV674TNLhYl2s0jTtNz+GKtOjSdZuRm1mAPwPG6ya
|
||||
K1b7iMRdBT92gNZMw30LbtcXmttCxpZAwr9lBLg4BEb33DwSCisGAQQBl1UBBQEB
|
||||
B0C5fFb4WTHoIoI6ou/31+1N1wn8ghsSkUVzpbtv/aTkegMBCAeIeAQYFgoAIBYh
|
||||
BCi19YqvugVVkhyHgicqAms0FFoiBQJG99w8AhsMAAoJECcqAms0FFoi8z8BALPL
|
||||
7V0ICLEY5YSUa4lQ2rjiXOcVTlWkG3h4TATPrr08AP9tIAQIE0o50IGdQAcKJoTn
|
||||
Lyxnf2wfjZ16vL3JLLjSBrg4BEb33DwSCisGAQQBl1UBBQEBB0DXDrcGrnuLjAUO
|
||||
eo/t8MQNOe+ZKYSDPGTkO7iM5IloSAMBCAeIfgQYFgoAJhYhBCi19YqvugVVkhyH
|
||||
gicqAms0FFoiBQJG99w8AhsMBQkB4TOAAAoJECcqAms0FFoivQIA/2PcZ1vcImAa
|
||||
7ldPY00JkcW6WlSSd6yOIZsVa4TdA1FiAP42gOji+4RrLps2+NX6L1znSc8EJBXo
|
||||
RMbND/CZQWXfA7g4BEb33DwSCisGAQQBl1UBBQEBB0BHxCvo5zuygw2XiluYNobx
|
||||
7iFJqlmCkjekKyoVFquKHQMBCAeIeAQYFgoAIBYhBCi19YqvugVVkhyHgicqAms0
|
||||
FFoiBQJG99w8AhsMAAoJECcqAms0FFoirSMA/0gVP98sPFga+UhQ3uJxJw5bO2Rs
|
||||
7hxVk6aPREWgBYg1AQCT7AE8m7j17SP/1fl8OjpxsQQmCJyv2wNcP48OfKGOCA==
|
||||
=AXAi
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
";
|
||||
|
||||
let alice_tpk = SignedPublicKey::from_asc(alice_tpk_asc).unwrap();
|
||||
let now = pgp::types::Timestamp::now();
|
||||
let encryption_subkey = select_pk_for_encryption(now.as_secs(), &alice_tpk).unwrap();
|
||||
assert_eq!(
|
||||
encryption_subkey.fingerprint().to_string().as_str(),
|
||||
expected_fallback_fingerprint
|
||||
);
|
||||
|
||||
// If we pass 1199149200 as the `now` argument, which was in the beginning of 2008,
|
||||
// then the expiring subkey is not yet expired and should be used.
|
||||
let encryption_subkey = select_pk_for_encryption(1199149200, &alice_tpk).unwrap();
|
||||
assert_eq!(
|
||||
encryption_subkey.fingerprint().to_string().as_str(),
|
||||
expected_expiring_fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
/// Tests that key selection fails if there is only an expired subkey.
|
||||
#[test]
|
||||
fn test_select_only_expired_subkey() {
|
||||
let alice_tpk_asc = "
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMERvfcPBYJKwYBBAHaRw8BAQdAUz6AZXIRE8T04Vh8RReFiP3tEV8UfSs2EiYs
|
||||
8b8i4ce0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz6IkAQTFgoAOBYhBNRG3w/A
|
||||
qWyPitBaqkGLyqDx3CaUBQJG99w8AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
|
||||
AAoJEEGLyqDx3CaUMjIA/Rq9/iORLP360s6EsIDe9qSmlmSCggtivafH+uVBWa0X
|
||||
AP0Wb+kailDwISq2O9Ef/jceZw7ozyzDLeDskKpCYiL3A7g4BEb33DwSCisGAQQB
|
||||
l1UBBQEBB0BXhbrks9iskW1GfT3B022W3KhJCgz8gw81z9lAWv7OZgMBCAeIfgQY
|
||||
FgoAJhYhBNRG3w/AqWyPitBaqkGLyqDx3CaUBQJG99w8AhsMBQkB4TOAAAoJEEGL
|
||||
yqDx3CaU6/YA/jaYJsZMXvu5grrMq1wq3Z/yzGXd15zeWp+alY/6UYxoAQCmSrBC
|
||||
SOtE3ODLZN9tC3F7k1N9clme1cHXyUiH3EliBQ==
|
||||
=zWPq
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
";
|
||||
|
||||
let alice_tpk = SignedPublicKey::from_asc(alice_tpk_asc).unwrap();
|
||||
let now = pgp::types::Timestamp::now();
|
||||
|
||||
assert_eq!(select_pk_for_encryption(now.as_secs(), &alice_tpk), None);
|
||||
}
|
||||
|
||||
/// Tests that the key with the closest expiration date is selected.
|
||||
#[test]
|
||||
fn test_expiring_subkey_selection() {
|
||||
// The key has the following keys:
|
||||
// 1. Primary non-expiring Ed25519 key created on 2025-01-01
|
||||
// 2. Non-expiring subkey CE5FC3FD479E41F08069DCE6F6CAD4ADB9AF47F1 created on 2025-01-01
|
||||
// 3. Subkey 10493EF4DBA7EE4D55C826A7634684D9BFEEB890 created on 2026-06-01 and expiring on 2026-07-01
|
||||
// 4. Subkey 477650C13FA4842BAF2831BC41C2D560BE27B38D created on 2026-06-01 and expiring on 2026-06-15
|
||||
// 5. Subkey AB92F4AFD46DDD7D31FB05151BE08A8089F67CA2 created on 2026-06-08 and expiring on 2026-06-22
|
||||
let alice_tpk_asc = "
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEZ3SFgBYJKwYBBAHaRw8BAQdAOrjYsxkYNvVtjbZwvglXk94Rd8S3F0YlIgcQ
|
||||
SSp45ni0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz6IkAQTFgoAOBYhBGV3iWZU
|
||||
bA6WJh3MR00wvB0X57PiBQJndIWAAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
|
||||
AAoJEE0wvB0X57PiAr0A/0qzulPRXZR1+D1fUeW6/C3BRP+8qRZTlOvF0XEnxPOV
|
||||
AQD7oePZ/QI6AuDMFmnJ5HQJ6cQSSOp/FwyucD8yiAoABrg4BGd0hYASCisGAQQB
|
||||
l1UBBQEBB0DBBk+BdzyTu3Hys6dewzd0L5AFeYMRIOC99XosMBLZYwMBCAeIeAQY
|
||||
FgoAIBYhBGV3iWZUbA6WJh3MR00wvB0X57PiBQJndIWAAhsMAAoJEE0wvB0X57Pi
|
||||
BxYA+gOtDVtb9p9vVaKyAuYCG7LdJ63Bqu0OtrVxmZEQvHQbAQDJmJX/yOX5QSkh
|
||||
cRtM1qoiY7OUjcT8ERnaoAcw2suFBLg4BGocy4ASCisGAQQBl1UBBQEBB0AgEDSA
|
||||
muuqS2iyi7aZeV608Xx2qdVqYKjREgiC3cu4YQMBCAeIfgQYFgoAJhYhBGV3iWZU
|
||||
bA6WJh3MR00wvB0X57PiBQJqHMuAAhsMBQkAJ40AAAoJEE0wvB0X57Pi9KgA/2vW
|
||||
4yNU1EF/WRCKmr87mJtmYR5Xf9Jps54Tv4BexNGgAQDxxx9lKvwW4U9/8UkrSF28
|
||||
HrGdWES4wAa9N9HvJzuOD7g4BGocy4ASCisGAQQBl1UBBQEBB0CK1TifDqKtFzks
|
||||
4E8gNmF90OkIYVriZxdY3QFSwowILQMBCAeIfgQYFgoAJhYhBGV3iWZUbA6WJh3M
|
||||
R00wvB0X57PiBQJqHMuAAhsMBQkAEnUAAAoJEE0wvB0X57PiZ+UA/0+s3fM/8OOb
|
||||
8Ft21QMFM/7Ce1P/ovKgkxsYWjk8q7zYAP9HYm/vcWBGzQw08mn3X26UFB9UfCF3
|
||||
qc1yyltDEeERDrg4BGomBgASCisGAQQBl1UBBQEBB0AvGvgG8Wkwx/KklkUHdLuC
|
||||
UKKSqTp4xsjocWXJs69rSwMBCAeIfgQYFgoAJhYhBGV3iWZUbA6WJh3MR00wvB0X
|
||||
57PiBQJqJgYAAhsMBQkAEnUAAAoJEE0wvB0X57PibzQA/A0qT23pcdenNJJ5QZN/
|
||||
ecs08p2pbiipv+adPeojVGZIAP47Pyr8Hj0o3qhrEhhFZkIhTxxDxS/jAsq2v4UG
|
||||
JGp8Ag==
|
||||
=RzZZ
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
";
|
||||
|
||||
let alice_tpk = SignedPublicKey::from_asc(alice_tpk_asc).unwrap();
|
||||
|
||||
// Two-week subkey that was generated earlier is selected
|
||||
// because it expires earlier, on 2026-06-15.
|
||||
{
|
||||
let ts = u32::try_from(
|
||||
Utc.with_ymd_and_hms(2026, 6, 9, 0, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
|
||||
assert_eq!(
|
||||
subkey.fingerprint().to_string().as_str(),
|
||||
"477650c13fa4842baf2831bc41c2d560be27b38d",
|
||||
);
|
||||
}
|
||||
|
||||
// Now both 477650C13FA4842BAF2831BC41C2D560BE27B38D expires
|
||||
// and we switch to another subkey that expires on 2026-06-22.
|
||||
{
|
||||
let ts = u32::try_from(
|
||||
Utc.with_ymd_and_hms(2026, 6, 16, 0, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
|
||||
assert_eq!(
|
||||
subkey.fingerprint().to_string().as_str(),
|
||||
"ab92f4afd46ddd7d31fb05151be08a8089f67ca2",
|
||||
);
|
||||
}
|
||||
|
||||
// Now both two-week subkeys have expired and we switch to expiring subkey that expires on 2026-07-01.
|
||||
{
|
||||
let ts = u32::try_from(
|
||||
Utc.with_ymd_and_hms(2026, 6, 23, 0, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
|
||||
assert_eq!(
|
||||
subkey.fingerprint().to_string().as_str(),
|
||||
"10493ef4dba7ee4d55c826a7634684d9bfeeb890",
|
||||
);
|
||||
}
|
||||
|
||||
// All expiring subkeys are expired, switching to non-expiring subkey.
|
||||
{
|
||||
let ts = u32::try_from(
|
||||
Utc.with_ymd_and_hms(2026, 7, 2, 0, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
|
||||
assert_eq!(
|
||||
subkey.fingerprint().to_string().as_str(),
|
||||
"ce5fc3fd479e41f08069dce6f6cad4adb9af47f1",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1032,6 +1032,7 @@ Content-Disposition: reaction\n\
|
||||
assert_eq!(summary.timestamp, bob_msg1.get_timestamp()); // time refers to message, not to reaction
|
||||
assert_eq!(summary.state, MessageState::InFresh); // state refers to message, not to reaction
|
||||
assert!(summary.prefix.is_none());
|
||||
assert!(summary.thumbnail_path.is_none());
|
||||
assert_summary(&alice, "BOB reacted 👍 to \"Party?\"").await;
|
||||
|
||||
// Alice reacts to own message as well
|
||||
|
||||
+7
-49
@@ -54,39 +54,6 @@ pub(crate) struct Smtp {
|
||||
pub(crate) last_send_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Returns transports with their IDs in the order in which they should be tried.
|
||||
async fn sorted_transports(context: &Context) -> Result<Vec<(u32, ConfiguredLoginParam)>> {
|
||||
context
|
||||
.sql
|
||||
.query_map_vec(
|
||||
"SELECT transports.id, configured_param FROM transports
|
||||
LEFT JOIN smtp_success ON smtp_success.transport_id=transports.id
|
||||
ORDER BY IFNULL(smtp_success.id, 0) DESC, transports.id ASC",
|
||||
(),
|
||||
|row| {
|
||||
let id: u32 = row.get(0)?;
|
||||
let json: String = row.get(1)?;
|
||||
let param = ConfiguredLoginParam::from_json(&json)?;
|
||||
Ok((id, param))
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Records successful use of SMTP transport so it is tried first next time we connect to SMTP.
|
||||
async fn record_success(context: &Context, transport_id: u32) -> Result<()> {
|
||||
// INSERT OR REPLACE essentially replaces rowid of the row
|
||||
// if the row exists already, so it becomes the highest rowid in the table.
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO smtp_success (transport_id) VALUES (?)",
|
||||
(transport_id,),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Smtp {
|
||||
/// Create a new Smtp instances.
|
||||
pub fn new() -> Self {
|
||||
@@ -134,7 +101,13 @@ impl Smtp {
|
||||
|
||||
self.connectivity.set_connecting(context);
|
||||
let proxy_config = ProxyConfig::load(context).await?;
|
||||
for (transport_id, lp) in sorted_transports(context).await? {
|
||||
let transports = ConfiguredLoginParam::load_all(context).await?;
|
||||
|
||||
// Try to connect to the newest transport first. If sending is unreliable,
|
||||
// user can configure a new transport and it will be the one used.
|
||||
// Conversely, if user just added a new transport and sending got less reliable,
|
||||
// user can restore old state by removing the just added transport.
|
||||
for (transport_id, lp) in transports.into_iter().rev() {
|
||||
info!(context, "Trying to connect to transport {transport_id}.");
|
||||
match self
|
||||
.connect(
|
||||
@@ -354,18 +327,6 @@ pub(crate) async fn smtp_send(
|
||||
Ok(()) => SendResult::Success,
|
||||
};
|
||||
|
||||
if matches!(status, SendResult::Success) {
|
||||
debug_assert!(smtp.transport_id.is_some());
|
||||
if let Some(transport_id) = smtp.transport_id
|
||||
&& let Err(err) = record_success(context, transport_id).await
|
||||
{
|
||||
warn!(
|
||||
context,
|
||||
"Failed to record successful use of transport {transport_id} in smtp_success table: {err:#}."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let SendResult::Failure(err) = &status
|
||||
&& let Some(msg_id) = msg_id
|
||||
{
|
||||
@@ -897,6 +858,3 @@ pub(crate) async fn add_self_recipients(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod smtp_tests;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::test_utils::TestContextManager;
|
||||
use crate::transport;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_smtp_candidates() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.unconfigured().await;
|
||||
|
||||
transport::add_pseudo_transport(t, "foo@example.net").await?;
|
||||
transport::add_pseudo_transport(t, "bar@example.net").await?;
|
||||
transport::add_pseudo_transport(t, "baz@example.net").await?;
|
||||
|
||||
let transports = super::sorted_transports(t).await?;
|
||||
let [
|
||||
(transport_id1, ref transport1),
|
||||
(transport_id2, ref transport2),
|
||||
(transport_id3, ref transport3),
|
||||
] = transports[..]
|
||||
else {
|
||||
panic!("Unexpected number of transports");
|
||||
};
|
||||
|
||||
// By default first added transport is used first.
|
||||
assert_eq!(transport1.addr, "foo@example.net");
|
||||
assert_eq!(transport2.addr, "bar@example.net");
|
||||
assert_eq!(transport3.addr, "baz@example.net");
|
||||
|
||||
super::record_success(t, transport_id3).await?;
|
||||
let transports2 = super::sorted_transports(t).await?;
|
||||
assert_eq!(transports2[0].0, transport_id3);
|
||||
assert_eq!(transports2[1].0, transport_id1);
|
||||
assert_eq!(transports2[2].0, transport_id2);
|
||||
|
||||
super::record_success(t, transport_id2).await?;
|
||||
let transports3 = super::sorted_transports(t).await?;
|
||||
assert_eq!(transports3[0].0, transport_id2);
|
||||
assert_eq!(transports3[1].0, transport_id3);
|
||||
assert_eq!(transports3[2].0, transport_id1);
|
||||
|
||||
super::record_success(t, transport_id3).await?;
|
||||
let transports4 = super::sorted_transports(t).await?;
|
||||
assert_eq!(transports4[0].0, transport_id3);
|
||||
assert_eq!(transports4[1].0, transport_id2);
|
||||
assert_eq!(transports4[2].0, transport_id1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2672,21 +2672,6 @@ CREATE TABLE smtp2 (
|
||||
.await?;
|
||||
}
|
||||
|
||||
inc_and_check(&mut migration_version, 168)?;
|
||||
if dbversion < migration_version {
|
||||
sql.execute_migration(
|
||||
"
|
||||
CREATE TABLE smtp_success (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
transport_id INTEGER UNIQUE NOT NULL,
|
||||
FOREIGN KEY(transport_id) REFERENCES transports(id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
",
|
||||
migration_version,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let new_version = sql
|
||||
.get_raw_config_int(VERSION_CFG)
|
||||
.await?
|
||||
|
||||
@@ -56,6 +56,9 @@ pub struct Summary {
|
||||
|
||||
/// Message state.
|
||||
pub state: MessageState,
|
||||
|
||||
/// Message preview image path
|
||||
pub thumbnail_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
@@ -80,6 +83,7 @@ impl Summary {
|
||||
text: msg_reacted(context, reaction_contact_id, &reaction, &summary).await,
|
||||
timestamp: msg.get_timestamp(), // message timestamp (not reaction) to make timestamps more consistent with chats ordering
|
||||
state: msg.state, // message state (not reaction) - indicating if it was me sending the last message
|
||||
thumbnail_path: None,
|
||||
});
|
||||
}
|
||||
Self::new(context, msg, chat, contact).await
|
||||
@@ -123,11 +127,24 @@ impl Summary {
|
||||
text = stock_str::reply_noun(context)
|
||||
}
|
||||
|
||||
let thumbnail_path = if msg.viewtype == Viewtype::Image
|
||||
|| msg.viewtype == Viewtype::Gif
|
||||
|| msg.viewtype == Viewtype::Sticker
|
||||
{
|
||||
msg.get_file(context)
|
||||
.and_then(|path| path.to_str().map(|p| p.to_owned()))
|
||||
} else if msg.viewtype == Viewtype::Webxdc {
|
||||
Some("webxdc-icon://last-msg-id".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Summary {
|
||||
prefix,
|
||||
text,
|
||||
timestamp: msg.get_timestamp(),
|
||||
state: msg.state,
|
||||
thumbnail_path,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1664,6 +1664,7 @@ async fn test_webxdc_chatlist_summary() -> Result<()> {
|
||||
assert_eq!(chatlist.len(), 1);
|
||||
let summary = chatlist.get_summary(&t, 0, None).await?;
|
||||
assert_eq!(summary.text, "📱 nice app!".to_string());
|
||||
assert_eq!(summary.thumbnail_path.unwrap(), "webxdc-icon://last-msg-id");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user