Compare commits

..

11 Commits

Author SHA1 Message Date
link2xt
eccb5c94d3 feat: merging and minimization of subkeys 2026-07-01 12:13:18 +00:00
link2xt
ffbc5e8927 feat: take key flags and expiration into account when selecting the key 2026-07-01 12:13:18 +00:00
link2xt
bd5b110e8a refactor: remove Context argument from secret_key_to_public_key() 2026-07-01 12:13:18 +00:00
link2xt
30bdcd6809 fix: do not put locations into pre-messages 2026-07-01 12:02:53 +00:00
link2xt
5eb5d22d7b fix: replace last_added_location_id with last_added_location_timestamp
Stored location ID was not actually used
and locations_last_sent timestamp was updated
with the current time, possibly skipping
locations.
2026-07-01 12:02:53 +00:00
dependabot[bot]
a22ba021e5 chore(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 11:49:38 +00:00
iequidoo
6e633eebe1 chore(deps): bump anyhow to 1.0.103 2026-06-29 19:08:41 -03:00
link2xt
a14e617059 feat: remove ?emailaddress argument from autoconfig URL that is not using a dedicated domain
This brings the implementation closer to the specification <https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/06/>
as it is confirmed that `?emailaddress` should not be included.
2026-06-29 18:07:17 +00:00
iequidoo
b4471f2434 feat: Remove trailing '.' from device message about receive_imf failure
This is to avoid from the Android UI treating it as a part of URL.
2026-06-29 14:33:52 -03:00
iequidoo
abaa48d0a1 fix: Return early from receive_imf to not tombstone Iroh-Node-Addr message if webxdc instance isn't found (#8372)
An Iroh-Node-Addr message (realtime advertisement) may arrive earlier than the referenced webxdc in
case of multi-transport. By not creating a tombstone we still can receive it later on another
transport.
2026-06-29 14:33:52 -03:00
link2xt
1b2ec2e88c chore: update astral-tokio-tar from 0.6.2 to 0.6.3 2026-06-27 06:04:18 +00:00
22 changed files with 430 additions and 75 deletions

View File

@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -73,7 +73,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -89,7 +89,7 @@ jobs:
env:
RUSTDOCFLAGS: -Dwarnings
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -128,7 +128,7 @@ jobs:
shell: bash
if: matrix.rust == 'latest'
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -171,7 +171,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -200,7 +200,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -226,7 +226,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -252,7 +252,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -302,7 +302,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -356,7 +356,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false

View File

@@ -30,7 +30,7 @@ jobs:
arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -54,7 +54,7 @@ jobs:
arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -78,7 +78,7 @@ jobs:
arch: [win32, win64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -102,7 +102,7 @@ jobs:
arch: [win32, win64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -127,7 +127,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -153,7 +153,7 @@ jobs:
arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -177,7 +177,7 @@ jobs:
arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -204,7 +204,7 @@ jobs:
contents: write
runs-on: "ubuntu-latest"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -397,7 +397,7 @@ jobs:
# Needed to publish the binaries to the release.
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false

View File

@@ -14,7 +14,7 @@ jobs:
name: Check that current version ends with -dev
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false

View File

@@ -17,7 +17,7 @@ jobs:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false

View File

@@ -16,7 +16,7 @@ jobs:
build_and_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false

View File

@@ -21,7 +21,7 @@ jobs:
name: check flake formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -79,7 +79,7 @@ jobs:
#- deltachat-rpc-server-x86_64-android
#- deltachat-rpc-server-x86-android
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -100,7 +100,7 @@ jobs:
# because of <https://github.com/NixOS/nixpkgs/issues/413910>.
# - deltachat-rpc-server-aarch64-darwin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false

View File

@@ -14,7 +14,7 @@ jobs:
name: Build REPL example
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false

View File

@@ -15,7 +15,7 @@ jobs:
url: https://rs.delta.chat/
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
url: https://py.delta.chat/
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -58,7 +58,7 @@ jobs:
url: https://c.delta.chat/
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -84,7 +84,7 @@ jobs:
working-directory: ./deltachat-jsonrpc/typescript
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
@@ -113,7 +113,7 @@ jobs:
url: https://cffi.delta.chat/
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false

View File

@@ -18,7 +18,7 @@ jobs:
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false

23
Cargo.lock generated
View File

@@ -124,9 +124,9 @@ checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc"
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "argon2"
@@ -194,15 +194,15 @@ dependencies = [
[[package]]
name = "astral-tokio-tar"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277"
checksum = "08648fef353ab39a9d26f909ad53fc4f071be4c91853b78523f5cc3d9e5ebffd"
dependencies = [
"filetime",
"futures-core",
"libc",
"portable-atomic",
"rustc-hash",
"rustix 0.38.44",
"tokio",
"tokio-stream",
]
@@ -2079,18 +2079,6 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
dependencies = [
"cfg-if",
"libc",
"libredox",
"windows-sys 0.59.0",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -3377,7 +3365,6 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags 2.11.0",
"libc",
"redox_syscall 0.5.12",
]
[[package]]

View File

@@ -103,7 +103,7 @@ 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 }
astral-tokio-tar = { version = "0.6.3", default-features = false }
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9"

View File

@@ -380,7 +380,7 @@ def test_receive_imf_failure(acfactory) -> None:
snapshot.text == "❌ Failed to receive a message:"
" Condition failed: `!context.get_config_bool(Config::SimulateReceiveImfError).await?`."
f" Core version {version}."
" Please report this bug to delta@merlinux.eu or https://support.delta.chat/."
" Please report this bug to delta@merlinux.eu or https://support.delta.chat/"
)
# The failed message doesn't break the IMAP loop.

View File

@@ -721,12 +721,12 @@ async fn get_autoconfig(
}
progress!(ctx, 300);
// `?emailaddress=` query string is excluded on purpose.
// It is not part of the URL according to <https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/06/>.
// Related discussion confirming this is at <https://github.com/benbucksch/autoconfig-spec/issues/17>.
if let Ok(res) = moz_autoconfigure(
ctx,
// the doc does not mention `emailaddress=`, however, Thunderbird adds it, see <https://releases.mozilla.org/pub/thunderbird/>, which makes some sense
&format!(
"https://{param_domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={param_addr_urlencoded}"
),
&format!("https://{param_domain}/.well-known/autoconfig/mail/config-v1.1.xml"),
&param.addr,
accept_invalid_certificates,
)

View File

@@ -25,9 +25,10 @@ impl EncryptHelper {
}
pub fn get_aheader(&self) -> Aheader {
let public_key = pgp::minimize_autocrypt_certificate(&self.public_key);
Aheader {
addr: self.addr.clone(),
public_key: self.public_key.clone(),
public_key,
prefer_encrypt: EncryptPreference::Mutual,
verified: false,
}

View File

@@ -1284,7 +1284,9 @@ impl Session {
warn!(context, "receive_imf error: {err:#}.");
let text = format!(
"❌ Failed to receive a message: {err:#}. Core version v{DC_VERSION_STR}. Please report this bug to delta@merlinux.eu or https://support.delta.chat/.",
// No trailing '.' to avoid from the Android UI treating it as a part of
// URL.
"❌ Failed to receive a message: {err:#}. Core version v{DC_VERSION_STR}. Please report this bug to delta@merlinux.eu or https://support.delta.chat/",
);
let mut msg = Message::new_text(text);
add_device_msg(context, None, Some(&mut msg)).await?;

View File

@@ -124,13 +124,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.
@@ -298,7 +296,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))

View File

@@ -1153,4 +1153,44 @@ Content-Disposition: attachment; filename="location.kml"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_last_sent_location_timestamp() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
send_to_chat(alice, alice_chat.id, 600).await?;
bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(set(alice, 10.0, 20.0, 1.0).await?, true);
SystemTime::shift(Duration::from_secs(60));
maybe_send(alice).await?;
bob.recv_msg_opt(&alice.pop_sent_msg().await).await;
let alice_locations = get_range(alice, None, None, 0, 0).await?;
assert_eq!(alice_locations.len(), 1);
assert_eq!(get_range(bob, None, None, 0, 0).await?.len(), 1);
let last_sent = alice
.sql
.query_row(
"SELECT locations_last_sent FROM chats WHERE id = ?",
(alice_chat.id,),
|row| {
let last_sent: i64 = row.get(0)?;
Ok(last_sent)
},
)
.await?;
assert_eq!(alice_locations[0].timestamp, last_sent);
Ok(())
}
}

View File

@@ -1095,9 +1095,11 @@ impl MimeFactory {
continue;
}
let public_key = crate::pgp::minimize_autocrypt_certificate(&key);
let header = Aheader {
addr: addr.clone(),
public_key: key.clone(),
public_key,
// Autocrypt 1.1.0 specification says that
// `prefer-encrypt` attribute SHOULD NOT be included.
prefer_encrypt: EncryptPreference::NoPreference,

View File

@@ -589,6 +589,7 @@ mod tests {
EventType,
chat::{self, ChatId, add_contact_to_chat, resend_msgs, send_msg},
message::{Message, Viewtype},
receive_imf::receive_imf,
test_utils::{TestContext, TestContextManager},
};
@@ -759,6 +760,67 @@ mod tests {
assert!(alice.iroh.read().await.is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_duplicated_out_of_order_advertisement() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
let mut instance = Message::new(Viewtype::File);
instance.set_file_from_bytes(
alice,
"minimal.xdc",
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)?;
send_msg(alice, alice_chat.id, &mut instance).await?;
let alice_webxdc = alice.get_last_msg().await;
assert_eq!(alice_webxdc.get_viewtype(), Viewtype::Webxdc);
let webxdc = alice.pop_sent_msg().await;
// Imagine that at this point Alice learns about Bob's new transport...
send_webxdc_realtime_advertisement(alice, alice_webxdc.id).await?;
let advertisement = alice.pop_sent_msg().await;
// Bob receives an out-of-order advertisement from his new transport.
receive_imf(bob, advertisement.payload().as_bytes(), false).await?;
let bob_webxdc = bob.recv_msg(&webxdc).await;
assert_eq!(bob_webxdc.get_viewtype(), Viewtype::Webxdc);
bob_webxdc.chat_id.accept(bob).await?;
bob.recv_msg_trash(&advertisement).await;
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeAdvertisementReceived { msg_id } = event.typ {
assert!(msg_id == bob_webxdc.id);
break;
}
}
let members = get_iroh_gossip_peers(bob, bob_webxdc.id)
.await?
.into_iter()
.map(|addr| addr.node_id)
.collect::<Vec<_>>();
assert_eq!(
members,
vec![
alice
.get_or_try_init_peer_channel()
.await
.unwrap()
.get_node_addr()
.await
.unwrap()
.node_id
]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_can_reconnect() {
let mut tcm = TestContextManager::new();

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,
@@ -83,13 +85,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 +171,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(
@@ -291,6 +344,161 @@ pub fn symm_encrypt_message(
Ok(encoded_msg)
}
/// 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
@@ -327,7 +535,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,
@@ -403,7 +611,55 @@ pub fn merge_openpgp_certificates(
});
let users: Vec<SignedUser> = best_user.into_iter().collect();
let public_subkeys = old_public_subkeys;
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();
Ok(SignedPublicKey {
primary_key: old_primary_key,

View File

@@ -61,7 +61,7 @@ use crate::{logged_debug_assert, mimeparser};
///
/// One email with multiple attachments can end up as multiple chat messages, but they
/// all have the same chat_id, state and sort_timestamp.
#[derive(Debug)]
#[derive(Debug, Default)]
pub struct ReceivedMsg {
/// Chat the message is assigned to.
pub chat_id: ChatId,
@@ -2074,8 +2074,15 @@ async fn add_parts(
None => {
warn!(
context,
"Cannot add iroh peer because WebXDC instance does not exist."
"Cannot add iroh peer because WebXDC instance {in_reply_to} does not exist."
);
return Ok(ReceivedMsg {
chat_id,
state,
hidden: true,
sort_timestamp,
..Default::default()
});
}
},
None => {