mirror of
https://github.com/chatmail/core.git
synced 2026-06-14 11:46:32 +03:00
Compare commits
13 Commits
iequidoo/d
...
link2xt/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f08a85f659 | ||
|
|
7cd482157b | ||
|
|
bf085364ce | ||
|
|
d307071b67 | ||
|
|
6cd5b21a26 | ||
|
|
87c1fb2118 | ||
|
|
e8b94781a5 | ||
|
|
07231f28ae | ||
|
|
64f0d2352c | ||
|
|
93bf3d6ebb | ||
|
|
f05336f793 | ||
|
|
8df028e9a8 | ||
|
|
40309ce857 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -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
21
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
7
STYLE.md
7
STYLE.md
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "2.51.0-dev"
|
||||
"version": "2.52.0-dev"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.51.0-dev"
|
||||
"version": "2.52.0-dev"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026-05-29
|
||||
2026-06-09
|
||||
37
src/chat.rs
37
src/chat.rs
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
21
src/key.rs
21
src/key.rs
@@ -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.
|
||||
|
||||
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
588
src/pgp/autocrypt2.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user