Compare commits

..

13 Commits

Author SHA1 Message Date
link2xt
f08a85f659 WIP: feat: Autocrypt2 2026-06-12 23:45:49 +02:00
link2xt
7cd482157b feat: merging and minimization of subkeys 2026-06-12 23:45:49 +02:00
link2xt
bf085364ce feat: take key flags and expiration into account when selecting the key 2026-06-12 18:10:27 +02:00
link2xt
d307071b67 refactor: remove Context argument from secret_key_to_public_key() 2026-06-12 18:10:27 +02:00
holger krekel
6cd5b21a26 fix: don't send or process webxdc status updates in pre-messages
it makes no sense to send or receive status updates in pre-messages for large webxdc attachments because they can't be processed anyway.
2026-06-12 17:01:46 +02:00
dependabot[bot]
87c1fb2118 chore(deps): bump EmbarkStudios/cargo-deny-action from 2.0.19 to 2.0.20
Bumps [EmbarkStudios/cargo-deny-action](https://github.com/embarkstudios/cargo-deny-action) from 2.0.19 to 2.0.20.
- [Release notes](https://github.com/embarkstudios/cargo-deny-action/releases)
- [Commits](a531616d8c...bb137d7af7)

---
updated-dependencies:
- dependency-name: EmbarkStudios/cargo-deny-action
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-12 09:58:44 +00:00
link2xt
e8b94781a5 fix: do not trash pre-messages without text but with a webxdc update
Webxdc updates go to pre-message and when the message has no text,
the whole pre-message is trashed if it has a webxdc update.
The solution is not to trash pre-message in this case so preview is displayed.
2026-06-11 15:10:14 +00:00
dependabot[bot]
07231f28ae chore(deps): bump taiki-e/install-action from 2.79.10 to 2.81.1
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.79.10 to 2.81.1.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](60ae4ce63c...e49978b799)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.81.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 14:31:02 +00:00
iequidoo
64f0d2352c docs(STYLE.md): Require to list columns explicitly in INSERT statements 2026-06-09 12:34:38 -03:00
link2xt
93bf3d6ebb chore: bump version to 2.52.0-dev 2026-06-09 02:18:40 +02:00
link2xt
f05336f793 chore(release): prepare for 2.52.0 2026-06-09 00:40:07 +02:00
link2xt
8df028e9a8 build(nix): fix windows cross-compilation 2026-06-08 17:17:10 +00:00
link2xt
40309ce857 chore: add exception for unmaintained proc-macro-error2 to deny.toml 2026-06-08 17:16:10 +00:00
25 changed files with 951 additions and 72 deletions

View File

@@ -62,7 +62,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823
- uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe
with:
arguments: --workspace --all-features --locked
command: check
@@ -146,7 +146,7 @@ jobs:
cache-bin: false
- name: Install nextest
uses: taiki-e/install-action@60ae4ce63c7aeb6e96d7f572c1ec7fafbb17ca80
uses: taiki-e/install-action@e49978b799e49ff429d162b7a30601a569ab6538
with:
tool: nextest

View File

@@ -1,5 +1,37 @@
# Changelog
## [2.52.0] - 2026-06-09
### Fixes
- Update the channel title after joining if the QR code included a wrong title ([#8260](https://github.com/chatmail/core/pull/8260)).
- Don't send removal message to contact that hasn't been a chat member ([#8298](https://github.com/chatmail/core/pull/8298)).
### Features / Changes
- Add cryptography-related statistics (`number_of_transports`, `key_version`, `key_algorithm`, `pubkey_size`, `number_of_keys`) ([#8293](https://github.com/chatmail/core/pull/8293), [#8297](https://github.com/chatmail/core/pull/8297)).
- Add IMAP folder to `Context::get_info()` ([#8285](https://github.com/chatmail/core/pull/8285)).
### Miscellaneous Tasks
- Update preloaded DNS cache.
- Use default aws-lc-rs cryptography provider for rustls.
- Add exception for unmaintained proc-macro-error2 to deny.toml.
- cargo: bump `pin-project` from 1.1.11 to 1.1.13.
- cargo: bump `tokio` from 1.52.1 to 1.52.3.
- cargo: bump `log` from 0.4.29 to 0.4.30.
- cargo: bump `serde_json` from 1.0.149 to 1.0.150.
- deps: bump EmbarkStudios/cargo-deny-action from 2.0.18 to 2.0.19.
- deps: bump taiki-e/install-action from 2.79.2 to 2.79.10.
### Build system
- nix: fix windows cross-compilation by adding pthreads includes.
### Refactor
- Remove support for building "source" packages for deltachat-rpc-server.
## [2.51.0] - 2026-05-29
### Features / Changes
@@ -8297,3 +8329,4 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.49.0]: https://github.com/chatmail/core/compare/v2.48.0..v2.49.0
[2.50.0]: https://github.com/chatmail/core/compare/v2.49.0..v2.50.0
[2.51.0]: https://github.com/chatmail/core/compare/v2.50.0..v2.51.0
[2.52.0]: https://github.com/chatmail/core/compare/v2.51.0..v2.52.0

21
Cargo.lock generated
View File

@@ -1350,7 +1350,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.51.0-dev"
version = "2.52.0-dev"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1378,6 +1378,7 @@ dependencies = [
"futures",
"futures-lite",
"hex",
"hkdf",
"http-body-util",
"humansize",
"hyper",
@@ -1459,7 +1460,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.51.0-dev"
version = "2.52.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1480,7 +1481,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.51.0-dev"
version = "2.52.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1496,7 +1497,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.51.0-dev"
version = "2.52.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1525,7 +1526,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.51.0-dev"
version = "2.52.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1710,7 +1711,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -2733,7 +2734,7 @@ dependencies = [
"hyper",
"libc",
"pin-project-lite",
"socket2 0.5.9",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -3880,7 +3881,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -5281,7 +5282,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -6147,7 +6148,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.1",
]
[[package]]

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.51.0-dev"
version = "2.52.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.89"
@@ -42,6 +42,7 @@ format-flowed = { path = "./format-flowed" }
ratelimit = { path = "./deltachat-ratelimit" }
anyhow = { workspace = true }
astral-tokio-tar = { version = "0.6.2", default-features = false }
async-broadcast = "0.7.2"
async-channel = { workspace = true }
async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] }
@@ -61,6 +62,7 @@ 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"
@@ -103,7 +105,6 @@ thiserror = { workspace = true }
tokio-io-timeout = "1.2.1"
tokio-rustls = { version = "0.26.2", default-features = false, features = ["aws-lc-rs", "tls12"] }
tokio-stream = { version = "0.1.17", features = ["fs"] }
astral-tokio-tar = { version = "0.6.2", default-features = false }
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9"

View File

@@ -59,6 +59,13 @@ If column is already declared without `NOT NULL`, use `IFNULL` function to provi
Use `HAVING COUNT(*) > 0` clause
to [prevent aggregate functions such as `MIN` and `MAX` from returning `NULL`](https://stackoverflow.com/questions/66527856/aggregate-functions-max-etc-return-null-instead-of-no-rows).
List columns explicitly in `INSERT` statements:
```
INSERT OR IGNORE INTO download (rfc724_mid, msg_id) VALUES (?,0);
```
Otherwise if a new column with default value is added in a future DB version, an upgraded DB can't
be used with the old code, e.g. after transferring a DB from a device running a newer version.
Don't delete unused columns too early, but maybe after several months/releases, unused columns are
still used by older versions, so deleting them breaks downgrading the core or importing a backup in
an older version. Also don't change the column type, consider adding a new column with another name

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,11 @@ ignore = [
# this should be fixed by upgrading to iroh 1.0 once it is released.
"RUSTSEC-2025-0134",
# Unmaintained proc-macro-error2
# Transitive dependency of typescript-type-def 0.5.13.
# <https://rustsec.org/advisories/RUSTSEC-2026-0173>
"RUSTSEC-2026-0173",
# rustls-webpki v0.102.8
# We cannot upgrade to >=0.103.10 because
# it is a transitive dependency of iroh 0.35.0

View File

@@ -147,6 +147,7 @@
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${pkgsWin64.stdenv.cc}/bin/${pkgsWin64.stdenv.cc.targetPrefix}cc";
CFLAGS_x86_64_pc_windows_gnu = "-I${pkgsWin64.windows.pthreads}/include";
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${TARGET_CC}"
@@ -203,6 +204,7 @@
src = pkgs.lib.cleanSource ./.;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
pkgs.nasm # aws-lc-sys requires it
];
depsBuildBuild = [
winCC
@@ -215,6 +217,7 @@
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${winCC}/bin/${winCC.targetPrefix}cc";
CFLAGS_i686_pc_windows_gnu = "-I${pkgsWin32.windows.pthreads}/include";
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${TARGET_CC}"

View File

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

View File

@@ -1 +1 @@
2026-05-29
2026-06-09

View File

@@ -3738,19 +3738,17 @@ pub(crate) async fn update_chat_contacts_table(
id: ChatId,
contacts: &BTreeSet<ContactId>,
) -> Result<()> {
// See add_to_chat_contacts_table() for reasoning.
let limit = cmp::max(time().saturating_add(TIMESTAMP_SENT_TOLERANCE), timestamp);
context
.sql
.transaction(move |transaction| {
// Bump `remove_timestamp` even for members from `contacts`.
// Bump `remove_timestamp` to at least `now`
// even for members from `contacts`.
// We add members from `contacts` back below.
transaction.execute(
"UPDATE chats_contacts SET
add_timestamp=MIN(add_timestamp, ?1),
remove_timestamp=MAX(MIN(remove_timestamp,?1), MIN(add_timestamp,?1)+1, ?)
"UPDATE chats_contacts
SET remove_timestamp=MAX(add_timestamp+1, ?)
WHERE chat_id=?",
(limit, timestamp, id),
(timestamp, id),
)?;
if !contacts.is_empty() {
@@ -3762,8 +3760,9 @@ pub(crate) async fn update_chat_contacts_table(
)?;
for contact_id in contacts {
// We bumped `remove_timestamp` for existing rows above,
// so on conflict it is enough to set `add_timestamp = remove_timestamp`.
// We bumped `add_timestamp` for existing rows above,
// so on conflict it is enough to set `add_timestamp = remove_timestamp`
// and this guarantees that `add_timestamp` is no less than `timestamp`.
statement.execute((id, contact_id, timestamp))?;
}
}
@@ -3780,24 +3779,17 @@ pub(crate) async fn add_to_chat_contacts_table(
chat_id: ChatId,
contact_ids: &[ContactId],
) -> Result<()> {
// Our clock may be slow, so limit stored timestamps with `timestamp` if it's bigger. This way
// we only cap remote timestamps if, in addition, remote changes arrive reordered or we do local
// changes. Also allow some tolerance, moreover, previous removals might lend time from the
// future.
let limit = cmp::max(time().saturating_add(TIMESTAMP_SENT_TOLERANCE), timestamp);
context
.sql
.transaction(move |transaction| {
let mut add_statement = transaction.prepare(
"INSERT INTO chats_contacts (chat_id, contact_id, add_timestamp) VALUES(?1, ?2, ?3)
ON CONFLICT (chat_id, contact_id)
DO UPDATE SET
remove_timestamp=MIN(remove_timestamp, ?4),
add_timestamp=MIN(MAX(add_timestamp,remove_timestamp,?3), ?4)",
DO UPDATE SET add_timestamp=MAX(remove_timestamp, ?3)",
)?;
for contact_id in contact_ids {
add_statement.execute((chat_id, contact_id, timestamp, limit))?;
add_statement.execute((chat_id, contact_id, timestamp))?;
}
Ok(())
})
@@ -3816,16 +3808,13 @@ pub(crate) async fn remove_from_chat_contacts_table(
contact_id: ContactId,
) -> Result<bool> {
let now = time();
// See add_to_chat_contacts_table() for reasoning.
let limit = now.saturating_add(TIMESTAMP_SENT_TOLERANCE);
let is_past_member = context
.sql
.execute(
"UPDATE chats_contacts SET
add_timestamp=MIN(add_timestamp, ?1),
remove_timestamp=MAX(MIN(remove_timestamp,?1), MIN(add_timestamp,?1)+1, ?)
"UPDATE chats_contacts
SET remove_timestamp=MAX(add_timestamp+1, ?)
WHERE chat_id=? AND contact_id=?",
(limit, now, chat_id, contact_id),
(now, chat_id, contact_id),
)
.await?
> 0;

View File

@@ -477,6 +477,10 @@ 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 {

View File

@@ -1036,6 +1036,10 @@ 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));

View File

@@ -17,10 +17,12 @@ 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;
@@ -124,13 +126,11 @@ 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.");
let timestamp = pgp::types::Timestamp::from_secs(timestamp);
// Subpackets that we want to share between DKS and User ID signature.
@@ -149,7 +149,7 @@ pub(crate) fn secret_key_to_public_key(
};
Ok(vec![
Subpacket::regular(SubpacketData::SignatureCreationTime(timestamp))?,
Subpacket::critical(SubpacketData::SignatureCreationTime(timestamp))?,
Subpacket::regular(SubpacketData::IssuerFingerprint(
signed_secret_key.fingerprint(),
))?,
@@ -298,7 +298,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_published_self_addrs().await?.join(",");
let signed_public_key =
secret_key_to_public_key(context, signed_secret_key, timestamp, &addr, &all_addrs)?;
secret_key_to_public_key(signed_secret_key, timestamp, &addr, &all_addrs)?;
*lock = Some(signed_public_key.clone());
Ok(Some(signed_public_key))
@@ -463,9 +463,16 @@ async fn generate_keypair(context: &Context) -> Result<SignedSecretKey> {
None => {
let start = tools::Time::now();
info!(context, "Generating keypair.");
let keypair = Handle::current()
.spawn_blocking(move || crate::pgp::create_keypair(addr))
.await??;
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??
};
store_self_keypair(context, &keypair).await?;
info!(

View File

@@ -1813,14 +1813,15 @@ impl MimeFactory {
HeaderDef::IrohGossipTopic.get_headername(),
mail_builder::headers::raw::Raw::new(topic).into(),
));
if let (Some(json), _) = context
.render_webxdc_status_update_object(
msg.id,
StatusUpdateSerial::MIN,
StatusUpdateSerial::MAX,
None,
)
.await?
if !matches!(self.pre_message_mode, PreMessageMode::Pre { .. })
&& let (Some(json), _) = context
.render_webxdc_status_update_object(
msg.id,
StatusUpdateSerial::MIN,
StatusUpdateSerial::MAX,
None,
)
.await?
{
parts.push(context.build_status_update_part(&json));
}

View File

@@ -1,6 +1,8 @@
//! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp).
use std::collections::{HashMap, HashSet};
use std::cmp::Ordering;
use std::collections::btree_map::Entry as BTreeMapEntry;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Cursor;
use anyhow::{Context as _, Result, ensure};
@@ -14,7 +16,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, Subpacket, SubpacketData};
use pgp::packet::{Signature, SignatureType, Subpacket, SubpacketData};
use pgp::types::{
CompressionAlgorithm, Imprint, KeyDetails, KeyVersion, Password, SignedUser, SigningKey as _,
StringToKey,
@@ -25,6 +27,8 @@ use tokio::runtime::Handle;
use crate::key::{DcKey, Fingerprint};
pub(crate) mod autocrypt2;
/// Preferred symmetric encryption algorithm.
const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128;
@@ -83,13 +87,63 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<SignedSecretKey> {
/// Selects a subkey of the public key to use for encryption.
///
/// Returns `None` if the public key cannot be used for encryption.
/// The key is selected according to
/// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-4.3-4>.
/// If multiple keys are available, the one that will expire sooner is selected.
///
/// TODO: take key flags and expiration dates into account
fn select_pk_for_encryption(key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
/// Returns `None` if the public key cannot be used for encryption.
fn select_pk_for_encryption(now: u32, key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
key.public_subkeys
.iter()
.find(|subkey| subkey.algorithm().can_encrypt())
.filter(|subkey| subkey.algorithm().can_encrypt())
.filter_map(|subkey| {
// TODO: deal with multiple signatures.
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)
}
/// Version of SEIPD packet to use.
@@ -119,10 +173,11 @@ pub async fn pk_encrypt(
Handle::current()
.spawn_blocking(move || {
let mut rng = thread_rng();
let now = pgp::types::Timestamp::now();
let pkeys = public_keys_for_encryption
.iter()
.filter_map(select_pk_for_encryption);
.filter_map(|key| select_pk_for_encryption(now.as_secs(), key));
let subpkts = {
let mut hashed = Vec::with_capacity(1 + public_keys_for_encryption.len() + 1);
hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime(
@@ -294,6 +349,76 @@ pub async fn symm_encrypt_message(
.await?
}
/// Minimizes the signatures of a subpacket.
///
/// Selects the newest subkey binding signature
/// and the newest revocation signature.
///
/// This function does not check if the signatures are valid
/// and whether there is at least one subkey binding signature.
/// 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.into_iter() {
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().map(|ts| ts.as_secs()) < signature.created().map(|ts| ts.as_secs())
}) {
newest_binding_signature = Some(signature)
}
}
SignatureType::SubkeyRevocation => {
if newest_revocation_signature.as_ref().is_none_or(|s| {
s.created().map(|ts| ts.as_secs()) < signature.created().map(|ts| ts.as_secs())
}) {
newest_revocation_signature = Some(signature)
}
}
_ => continue,
}
}
newest_revocation_signature
.into_iter()
.chain(newest_binding_signature)
.collect()
}
/// 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.into_iter() {
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
@@ -330,7 +455,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,
@@ -411,7 +536,50 @@ pub fn merge_openpgp_certificates(
});
let users: Vec<SignedUser> = best_user.into_iter().collect();
let public_subkeys = old_public_subkeys;
let (fallback_subkeys, 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);
// Keep 10 newest rotating subkeys to avoid storing indefinitely growing number of subkeys locally.
rotating_subkeys
.sort_by_key(|(_subkey, created_at_secs, _)| std::cmp::Reverse(created_at_secs));
rotating_subkeys.truncate(10);
let mut public_subkeys = rotating_subkeys;
if let Some(fallback_subkey) = fallback_subkey {
// 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.
public_subkeys.insert(0, fallback_subkey);
}
Ok(SignedPublicKey {
primary_key: old_primary_key,

588
src/pgp/autocrypt2.rs Normal file
View File

@@ -0,0 +1,588 @@
//! 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());
}
}

View File

@@ -826,7 +826,9 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
}
}
if let Some(ref status_update) = mime_parser.webxdc_status_update {
if let Some(ref status_update) = mime_parser.webxdc_status_update
&& !matches!(mime_parser.pre_message, PreMessageMode::Pre { .. })
{
let can_info_msg;
let instance = if mime_parser
.parts
@@ -1215,6 +1217,8 @@ async fn decide_chat_assignment(
// Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them
info!(context, "Email is probably just a draft (TRASH).");
true
} else if matches!(mime_parser.pre_message, PreMessageMode::Pre { .. }) {
false
} else if mime_parser.webxdc_status_update.is_some() && mime_parser.parts.len() == 1 {
if let Some(part) = mime_parser.parts.first() {
if part.typ == Viewtype::Text && part.msg.is_empty() {

View File

@@ -534,6 +534,17 @@ async fn test_webxdc_updates_in_post_message_after_pre_message() -> Result<()> {
let alice_chat_id = alice.create_chat_id(bob).await;
// regression test where updates get assigned to an unrelated prior webxdc message
let mut unrelated_xdc = Message::new(Viewtype::Webxdc);
unrelated_xdc.set_file_from_bytes(
alice,
"first.xdc",
include_bytes!("../../../test-data/webxdc/minimal.xdc"),
None,
)?;
send_msg(alice, alice_chat_id, &mut unrelated_xdc).await?;
let bob_unrelated_webxdc = bob.recv_msg(&alice.pop_sent_msg().await).await;
let big_webxdc_app = big_webxdc_app().await?;
let mut alice_instance = Message::new(Viewtype::Webxdc);
@@ -552,6 +563,14 @@ async fn test_webxdc_updates_in_post_message_after_pre_message() -> Result<()> {
let bob_instance = bob.recv_msg(&pre_message).await;
assert_eq!(bob_instance.download_state, DownloadState::Available);
// don't accidentally assign updates from a pre-message to parent message
assert_eq!(
bob.get_webxdc_status_updates(bob_unrelated_webxdc.id, StatusUpdateSerial::new(0))
.await?,
"[]"
);
bob.recv_msg_trash(&post_message).await;
let bob_instance = Message::load_from_db(bob, bob_instance.id).await?;
assert_eq!(bob_instance.download_state, DownloadState::Done);
@@ -565,6 +584,51 @@ async fn test_webxdc_updates_in_post_message_after_pre_message() -> Result<()> {
Ok(())
}
/// Tests sending large webxdc without text.
///
/// This is a regression test, previously pre-message
/// was trashed when it had no text.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_large_webxdc_without_text() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.section("Bob sends large webxdc without attached text message.");
let bob_chat_id = bob.create_chat_id(alice).await;
let big_webxdc_app = big_webxdc_app().await?;
let mut bob_instance = Message::new(Viewtype::Webxdc);
bob_instance.set_file_from_bytes(bob, "test.xdc", &big_webxdc_app, None)?;
bob_chat_id.set_draft(bob, Some(&mut bob_instance)).await?;
bob.send_webxdc_status_update(bob_instance.id, r#"{"payload":42, "info":"i"}"#)
.await?;
send_msg(bob, bob_chat_id, &mut bob_instance).await?;
let post_message = bob.pop_sent_msg().await;
let pre_message = bob.pop_sent_msg().await;
tcm.section("Alice receives a pre-message");
let alice_instance = alice.recv_msg(&pre_message).await;
assert_eq!(alice_instance.download_state, DownloadState::Available);
tcm.section("Alice receives a post-message");
alice.recv_msg_trash(&post_message).await;
let alice_instance = Message::load_from_db(alice, alice_instance.id).await?;
assert_eq!(alice_instance.download_state, DownloadState::Done);
let alice_file_path = alice_instance.get_file(alice).expect("No file");
tokio::fs::try_exists(alice_file_path).await?;
assert_eq!(
alice
.get_webxdc_status_updates(alice_instance.id, StatusUpdateSerial::new(0))
.await?,
r#"[{"payload":42,"info":"i","serial":1,"max_serial":1}]"#
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_webxdc_updates_in_post_message_after_deleted_pre_message() -> Result<()> {
let mut tcm = TestContextManager::new();