Compare commits

..
57 changed files with 1723 additions and 1008 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ jobs:
cache-bin: false
- name: Install nextest
uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172
uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc
with:
tool: nextest
+7 -7
View File
@@ -34,7 +34,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
@@ -58,7 +58,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
@@ -82,7 +82,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
@@ -106,7 +106,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
@@ -157,7 +157,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
@@ -181,7 +181,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
@@ -208,7 +208,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v7
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- run: nix fmt flake.nix nix/ -- --check
build:
@@ -85,7 +85,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- run: nix build .#${{ matrix.installable }}
build-macos:
@@ -106,5 +106,5 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- run: nix build .#${{ matrix.installable }}
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary
+2 -2
View File
@@ -41,7 +41,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- name: Build Python documentation
run: nix build .#python-docs
- name: Upload to py.delta.chat
@@ -63,7 +63,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
Generated
+70 -48
View File
@@ -310,7 +310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37dd6b179962fe4048a6f81d4c0d7ed419a21fdf49204b4c6b04971693358e79"
dependencies = [
"native-tls",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"url",
]
@@ -327,7 +327,7 @@ dependencies = [
"log",
"nom 8.0.0",
"pin-project",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
]
@@ -363,7 +363,7 @@ dependencies = [
"crc32fast",
"futures-lite",
"pin-project",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"tokio-util",
]
@@ -459,7 +459,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"thiserror 2.0.21",
"thiserror 2.0.20",
]
[[package]]
@@ -1355,6 +1355,7 @@ dependencies = [
"futures",
"futures-lite",
"hex",
"hkdf",
"http-body-util",
"humansize",
"hyper",
@@ -1386,6 +1387,7 @@ dependencies = [
"regex",
"rusqlite",
"sanitize-filename",
"sdp",
"serde",
"serde_json",
"sha-1",
@@ -1398,7 +1400,7 @@ dependencies = [
"tempfile",
"testdir",
"textwrap",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"tokio-io-timeout",
"tokio-rustls",
@@ -1502,7 +1504,7 @@ name = "deltachat_derive"
version = "2.0.0"
dependencies = [
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@@ -1517,7 +1519,7 @@ dependencies = [
"num-traits",
"rand 0.9.4",
"serde_json",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"yerpc",
]
@@ -1676,9 +1678,9 @@ dependencies = [
[[package]]
name = "dirs"
version = "7.0.0"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
@@ -2221,7 +2223,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@@ -2464,7 +2466,7 @@ dependencies = [
"once_cell",
"rand 0.9.4",
"ring",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tinyvec",
"tokio",
"tracing",
@@ -2487,7 +2489,7 @@ dependencies = [
"rand 0.9.4",
"resolv-conf",
"smallvec",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"tracing",
]
@@ -2653,9 +2655,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.11.1"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
@@ -2693,16 +2695,15 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.21"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http 1.1.0",
"http-body",
"httparse",
"hyper",
"libc",
"pin-project-lite",
@@ -3045,7 +3046,7 @@ dependencies = [
"strum 0.26.2",
"stun-rs",
"surge-ping",
"thiserror 2.0.21",
"thiserror 2.0.20",
"time",
"tokio",
"tokio-stream",
@@ -3070,7 +3071,7 @@ dependencies = [
"ed25519-dalek",
"rand_core 0.6.4",
"serde",
"thiserror 2.0.21",
"thiserror 2.0.20",
"url",
]
@@ -3112,7 +3113,7 @@ dependencies = [
"rand_core 0.6.4",
"serde",
"serde-error",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"tokio-util",
"tracing",
@@ -3157,7 +3158,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2 0.5.9",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"tracing",
"web-time",
@@ -3177,7 +3178,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"web-time",
@@ -3232,7 +3233,7 @@ dependencies = [
"sha1",
"strum 0.26.2",
"stun-rs",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
"tokio-util",
@@ -3450,9 +3451,9 @@ checksum = "4c942e8a4b83f9351236c1e531ea9fa0237913d63c7fc36818430e0128a1ddf3"
[[package]]
name = "mailparse"
version = "0.17.0"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8469955699e572455f8e1a40689e07ab309911d659632f65062598192254c091"
checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f"
dependencies = [
"charset",
"data-encoding",
@@ -3717,7 +3718,7 @@ dependencies = [
"log",
"netlink-packet-core",
"netlink-sys",
"thiserror 2.0.21",
"thiserror 2.0.20",
]
[[package]]
@@ -4206,7 +4207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc"
dependencies = [
"memchr",
"thiserror 2.0.21",
"thiserror 2.0.20",
"ucd-trie",
]
@@ -4382,7 +4383,7 @@ dependencies = [
"serde",
"sha1_smol",
"simple-dns",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"tracing",
"url",
@@ -4780,7 +4781,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2 0.5.9",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"tracing",
]
@@ -4801,7 +4802,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"web-time",
@@ -5040,7 +5041,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 2.0.21",
"thiserror 2.0.20",
]
[[package]]
@@ -5444,6 +5445,18 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdp"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22c3b0257608d7de4de4c4ea650ccc2e6e3e45e3cd80039fcdee768bcb449253"
dependencies = [
"rand 0.9.4",
"substring",
"thiserror 1.0.69",
"url",
]
[[package]]
name = "sec1"
version = "0.7.3"
@@ -5549,7 +5562,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@@ -5705,7 +5718,7 @@ dependencies = [
"shadowsocks-crypto",
"socket2 0.5.9",
"spin 0.10.1",
"thiserror 2.0.21",
"thiserror 2.0.20",
"tokio",
"tokio-tfo",
"trait-variant",
@@ -5817,9 +5830,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.16.1"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "smawk"
@@ -6002,6 +6015,15 @@ dependencies = [
"rand 0.9.4",
]
[[package]]
name = "substring"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86"
dependencies = [
"autocfg",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -6048,9 +6070,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.6"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
@@ -6194,11 +6216,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.21"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl 2.0.21",
"thiserror-impl 2.0.20",
]
[[package]]
@@ -6214,13 +6236,13 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.21"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@@ -6340,9 +6362,9 @@ dependencies = [
[[package]]
name = "tokio-rustls"
version = "0.26.5"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
@@ -6742,9 +6764,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.1"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -7408,7 +7430,7 @@ dependencies = [
"futures",
"log",
"serde",
"thiserror 2.0.21",
"thiserror 2.0.20",
"windows 0.59.0",
"windows-core 0.59.0",
]
+4 -2
View File
@@ -36,6 +36,7 @@ strip = true
[dependencies]
deltachat_derive = { path = "./deltachat_derive" }
deltachat-time = { path = "./deltachat-time" }
deltachat-contact-tools = { workspace = true }
format-flowed = { path = "./format-flowed" }
ratelimit = { path = "./deltachat-ratelimit" }
@@ -60,6 +61,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"
@@ -86,6 +88,7 @@ rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
sanitize-filename = { workspace = true }
sdp = "0.17.1"
serde_json = { workspace = true }
serde = { workspace = true, features = ["derive"] }
sha-1 = "0.10"
@@ -113,7 +116,6 @@ webpki-roots = "0.26.8"
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
criterion = { version = "0.8.1", features = ["async_tokio"] }
deltachat-time = { path = "./deltachat-time" }
futures-lite = { workspace = true }
log = { workspace = true }
nu-ansi-term = { workspace = true }
@@ -188,7 +190,7 @@ futures = "0.3.32"
futures-lite = "2.6.1"
libc = "0.2"
log = "0.4"
mailparse = "0.17.0"
mailparse = "0.16.1"
nu-ansi-term = "0.50"
num-traits = "0.2"
rand = "0.9"
+3 -3
View File
@@ -21,9 +21,9 @@ fn main() {
url = env::var("CARGO_PKG_HOMEPAGE").unwrap_or_else(|_| "".to_string()),
version = env::var("CARGO_PKG_VERSION").unwrap(),
libs_priv = libs_priv,
prefix = option_env!("PREFIX").unwrap_or_else(|| "/usr/local"),
libdir = option_env!("LIBDIR").unwrap_or_else(|| "/usr/local/lib"),
includedir = option_env!("INCLUDEDIR").unwrap_or_else(|| "/usr/local/include"),
prefix = env::var("PREFIX").unwrap_or_else(|_| "/usr/local".to_string()),
libdir = env::var("LIBDIR").unwrap_or_else(|_| "/usr/local/lib".to_string()),
includedir = env::var("INCLUDEDIR").unwrap_or_else(|_| "/usr/local/include".to_string()),
);
fs::create_dir_all(target_path.join("pkgconfig")).unwrap();
+1 -1
View File
@@ -8,7 +8,7 @@ repository = "https://github.com/chatmail/core"
[dependencies]
anyhow = { workspace = true }
deltachat = { workspace = true, features = ["internals"]}
dirs = "7"
dirs = "6"
log = { workspace = true }
nu-ansi-term = { workspace = true }
qr2term = "0.3.3"
+1 -49
View File
@@ -20,7 +20,7 @@ use deltachat::message::{self, Message, MessageState, MsgId, Viewtype};
use deltachat::mimeparser::SystemMessage;
use deltachat::peer_channels::{send_webxdc_realtime_advertisement, send_webxdc_realtime_data};
use deltachat::qr::*;
use deltachat::qr_code_generator::{create_qr_svg, get_securejoin_qr_svg};
use deltachat::qr_code_generator::create_qr_svg;
use deltachat::reaction::send_reaction;
use deltachat::receive_imf::*;
use deltachat::sql;
@@ -346,57 +346,9 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
============================================="
),
},
"connect" => {
context.start_io().await;
}
"disconnect" => {
context.stop_io().await;
}
"fetch" => {
context.background_fetch().await?;
}
"configure" => {
context.configure().await?;
}
"has-backup" => {
has_backup(&context, blobdir).await?;
}
"clear" => {
println!("\n\n\n");
print!("\x1b[1;1H\x1b[2J");
}
"getqr" | "getbadqr" => {
context.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let mut qr = deltachat::securejoin::get_securejoin_qr(&context, group).await?;
if !qr.is_empty() {
if arg0 == "getbadqr" && qr.len() > 40 {
qr.replace_range(12..22, "0000000000")
}
println!("{qr}");
qr2term::print_qr(qr.as_str())?;
}
}
"getqrsvg" => {
context.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let file = dirs::home_dir().unwrap_or_default().join("qr.svg");
match get_securejoin_qr_svg(&context, group).await {
Ok(svg) => {
fs::write(&file, svg).await?;
println!("QR code svg written to: {file:#?}");
}
Err(err) => {
bail!("Failed to get QR code svg: {err}");
}
}
}
"joinqr" => {
context.start_io().await;
if !arg0.is_empty() {
deltachat::securejoin::join_securejoin(&context, arg1).await?;
}
}
"export-backup" => {
let dir = dirs::home_dir().unwrap_or_default();
imex(
+89 -8
View File
@@ -13,6 +13,8 @@ use anyhow::{Error, bail};
use deltachat::EventType;
use deltachat::chat::ChatId;
use deltachat::context::*;
use deltachat::qr_code_generator::get_securejoin_qr_svg;
use deltachat::securejoin::*;
use log::{error, info, warn};
use nu_ansi_term::Color;
use rustyline::completion::{Completer, FilenameCompleter, Pair};
@@ -23,6 +25,7 @@ use rustyline::validate::Validator;
use rustyline::{
Cmd, CompletionType, Config, Context as RustyContext, EditMode, Editor, Helper, KeyEvent,
};
use tokio::fs;
use tokio::runtime::Handle;
use tracing_subscriber::EnvFilter;
@@ -349,16 +352,23 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
Ok(line) => {
// TODO: ignore "set mail_pw"
rl.add_history_entry(line.as_str())?;
let line = line.trim();
if matches!(line, "exit" | "quit") {
println!("Exiting ...");
let should_continue = Handle::current().block_on(async {
match handle_cmd(line.trim(), ctx.clone(), &mut selected_chat).await {
Ok(ExitResult::Continue) => true,
Ok(ExitResult::Exit) => {
println!("Exiting ...");
false
}
Err(err) => {
eprintln!("Error: {err:#}");
true
}
}
});
if !should_continue {
break;
}
if let Err(err) =
Handle::current().block_on(cmdline(ctx.clone(), line, &mut selected_chat))
{
eprintln!("Error: {err:#}");
}
}
Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
println!("Exiting...");
@@ -382,6 +392,77 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
Ok(())
}
#[derive(Debug)]
enum ExitResult {
Continue,
Exit,
}
async fn handle_cmd(
line: &str,
ctx: Context,
selected_chat: &mut ChatId,
) -> Result<ExitResult, Error> {
let mut args = line.splitn(2, ' ');
let arg0 = args.next().unwrap_or_default();
let arg1 = args.next().unwrap_or_default();
match arg0 {
"connect" => {
ctx.start_io().await;
}
"disconnect" => {
ctx.stop_io().await;
}
"fetch" => {
ctx.background_fetch().await?;
}
"configure" => {
ctx.configure().await?;
}
"clear" => {
println!("\n\n\n");
print!("\x1b[1;1H\x1b[2J");
}
"getqr" | "getbadqr" => {
ctx.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let mut qr = get_securejoin_qr(&ctx, group).await?;
if !qr.is_empty() {
if arg0 == "getbadqr" && qr.len() > 40 {
qr.replace_range(12..22, "0000000000")
}
println!("{qr}");
qr2term::print_qr(qr.as_str())?;
}
}
"getqrsvg" => {
ctx.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let file = dirs::home_dir().unwrap_or_default().join("qr.svg");
match get_securejoin_qr_svg(&ctx, group).await {
Ok(svg) => {
fs::write(&file, svg).await?;
println!("QR code svg written to: {file:#?}");
}
Err(err) => {
bail!("Failed to get QR code svg: {err}");
}
}
}
"joinqr" => {
ctx.start_io().await;
if !arg0.is_empty() {
join_securejoin(&ctx, arg1).await?;
}
}
"exit" | "quit" => return Ok(ExitResult::Exit),
_ => cmdline(ctx.clone(), line, selected_chat).await?,
}
Ok(ExitResult::Continue)
}
#[tokio::main]
async fn main() -> Result<(), Error> {
tracing_subscriber::fmt()
@@ -351,11 +351,12 @@ def test_qr_works_after_removing_primary_transport(acf, log) -> None:
log.section("Alice creates a QR code")
chat_qr = alice.get_qr_code()
chat_qr_unquoted = urllib.parse.unquote(chat_qr)
assert f"&a={third_addr}" in chat_qr_unquoted
assert f"&r={second_addr},{first_addr}" in chat_qr_unquoted
assert f"&a={first_addr}" in chat_qr_unquoted
assert f"&r={third_addr},{second_addr}" in chat_qr_unquoted
log.section("Alice removes the transport named by the a= parameter")
alice.delete_transport(third_addr)
log.section("Alice removes first and second transport")
alice.set_config("configured_addr", third_addr)
alice.delete_transport(first_addr)
alice.delete_transport(second_addr)
log.section("Bob scans the QR code, which still works")
@@ -1,5 +1,4 @@
import logging
import time
import pytest
@@ -91,30 +90,6 @@ def test_qr_securejoin(acf):
fiona.wait_for_securejoin_joiner_success()
def test_qr_securejoin_request_deleted_on_all_transports(acf):
alice, bob = acf.get_online_accounts(2)
alice.add_transport_from_qr(acf.get_account_qr())
alice.bring_online()
alice_chat = alice.create_group("Group")
qr_code = alice_chat.get_qr_code()
alice2 = alice.clone()
bob.secure_join(qr_code)
alice.wait_for_securejoin_inviter_success()
alice_chat.remove_contact(bob)
# Stop and start io in order to wait until the remove message is sent out.
alice.stop_io()
alice.bring_online()
# Membership timestamps have a resolution of one second.
time.sleep(1)
# By now, the securejoin message sent by Bob must be deleted on Alice's relays.
# Otherwise, alice2 would execute securejoin again and re-add Bob.
alice2.bring_online()
alice2_chat = alice2.get_chat_by_id(alice_chat.id)
assert alice2.create_contact(bob) not in alice2_chat.get_contacts()
@pytest.mark.parametrize("all_devices_online", [True, False])
def test_qr_securejoin_broadcast(acf, all_devices_online):
alice, bob, fiona = acf.get_online_accounts(3)
+1 -3
View File
@@ -34,8 +34,6 @@ const DEFAULT_RELAY_CANDIDATES: &[&str] = &[
"chat.nuvon.app",
"chat.tinydispatch.org",
"chat.vim.wtf",
"chatmail.au",
"chatmail.cc",
"chatmail.uk",
"chtml.ca",
"deltachat.me",
@@ -165,7 +163,7 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
for _ in 0..NUM_TRANSPORTS_TARGET {
if context.count_transports().await? >= NUM_TRANSPORTS_TARGET {
context
.set_config_internal(Config::AutorelayFinished, Some(config::from_bool(true)))
.set_config_internal(Config::AutorelayFinished, config::from_bool(true))
.await?;
return Ok(relay_added);
+31 -26
View File
@@ -164,9 +164,9 @@ impl<'a> BlobObject<'a> {
/// you want to create a [BlobObject] for a filename read from the
/// database.
pub fn from_name(context: &'a Context, name: &str) -> Result<BlobObject<'a>> {
let name = match name.strip_prefix("$BLOBDIR/") {
Some(name) => name,
None => name,
let name = match name.starts_with("$BLOBDIR/") {
true => name.splitn(2, '/').last().unwrap(),
false => name,
};
if !BlobObject::is_acceptible_blob_name(name) {
return Err(format_err!("not an acceptable blob name: {name}"));
@@ -289,7 +289,6 @@ impl<'a> BlobObject<'a> {
name: Option<String>,
viewtype: &mut Viewtype,
) -> Result<String> {
debug_assert!(matches!(viewtype, Viewtype::File | Viewtype::Image));
let (max_wh, max_bytes) =
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
.unwrap_or_default()
@@ -327,7 +326,6 @@ impl<'a> BlobObject<'a> {
max_bytes: usize,
is_avatar: bool,
) -> Result<String> {
debug_assert!(matches!(viewtype, Viewtype::File | Viewtype::Image));
// Add white background only to avatars to spare the CPU.
let mut add_white_bg = is_avatar;
let mut no_exif = false;
@@ -342,38 +340,45 @@ impl<'a> BlobObject<'a> {
// It's strange that BufReader modifies a file position while it takes a non-mut
// reference. Ok, just rewind it.
file.rewind()?;
let imgreader = ImageReader::new(std::io::BufReader::new(&file))
.with_guessed_format()
.context("Failed to guess image format due to I/O error")?;
let imgreader = ImageReader::new(std::io::BufReader::new(&file)).with_guessed_format();
let imgreader = match imgreader {
Ok(ir) => ir,
_ => {
file.rewind()?;
ImageReader::with_format(
std::io::BufReader::new(&file),
ImageFormat::from_path(self.to_abs_path())?,
)
}
};
let fmt = imgreader.format().context("Unknown format")?;
if *vt == Viewtype::File {
*vt = Viewtype::Image;
return Ok(name);
}
let mut img = match fmt {
image::ImageFormat::WebP => {
// `with_guessed_format()` restores file position,
// so `buf_reader` is at the beginning of the file.
let buf_reader = imgreader.into_inner();
let webp_decoder = image::codecs::webp::WebPDecoder::new(buf_reader)
.context("Failed to create WebP decoder")?;
// If WebP has animation, do not try to recode it.
// Recoding into JPEG will result in losing the animation.
if !is_avatar && webp_decoder.has_animation() {
return Ok(name);
}
DynamicImage::from_decoder(webp_decoder)?
}
_ => imgreader.decode().context("Failed to decode image")?,
};
let mut img = imgreader.decode().context("image decode failure")?;
let orientation = exif
.as_ref()
.map(|exif| exif_orientation(exif, context))
.unwrap_or(Orientation::NoTransforms);
let mut encoded = Vec::new();
if *vt == Viewtype::Sticker {
let x_max = img.width().saturating_sub(1);
let y_max = img.height().saturating_sub(1);
if !img.in_bounds(x_max, y_max)
|| !(img.get_pixel(0, 0).0[3] == 0
|| img.get_pixel(x_max, 0).0[3] == 0
|| img.get_pixel(0, y_max).0[3] == 0
|| img.get_pixel(x_max, y_max).0[3] == 0)
{
*vt = Viewtype::Image;
} else {
// Core doesn't auto-assign `Viewtype::Sticker` to messages and stickers coming
// from UIs shouldn't contain sensitive Exif info.
return Ok(name);
}
}
img.apply_orientation(orientation);
// max_wh is the maximum image width and height, i.e. the resolution-limit,
+1 -63
View File
@@ -4,9 +4,7 @@ use super::*;
use crate::message::{Message, Viewtype};
use crate::param::Param;
use crate::sql;
use crate::test_utils::{
self, AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
};
use crate::test_utils::{self, AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext};
use crate::tools::SystemTime;
fn check_image_size(path: impl AsRef<Path>, width: u32, height: u32) -> image::DynamicImage {
@@ -740,66 +738,6 @@ async fn test_send_gif_as_sticker() -> Result<()> {
Ok(())
}
/// Tests that animated WebP is sent without reencoding.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_animated_webp_as_image() -> Result<()> {
let bytes = include_bytes!("../../test-data/image/animated.webp");
let (width, height) = (1280u32, 531u32);
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
alice
.set_config(
Config::MediaQuality,
Some(&(MediaQuality::Worse as i32).to_string()),
)
.await?;
let file = alice.get_blobdir().join("file").with_extension("gif");
fs::write(&file, &bytes)
.await
.context("Failed to write file")?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_and_deduplicate(alice, &file, Some("file.webp"), None)?;
let chat = alice.create_chat(bob).await;
let sent = alice.send_msg(chat.id, &mut msg).await;
let bob_msg = bob.recv_msg(&sent).await;
assert_eq!(bob_msg.get_viewtype(), Viewtype::Image);
assert_eq!(bob_msg.get_width() as u32, width);
assert_eq!(bob_msg.get_height() as u32, height);
assert_eq!(
bob_msg.get_filebytes(bob).await?.unwrap(),
bytes.len() as u64
);
Ok(())
}
/// Tests that if user sets animated WebP as an avatar, it may be recoded.
///
/// We don't want to recode animated WebPs into JPEG and lose animation,
/// but for avatars we don't want animation and transparency anyway.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_animated_webp_avatar() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let avatar_src = t.dir.path().join("avatar.webp");
let avatar_bytes = include_bytes!("../../test-data/image/animated.webp");
fs::write(&avatar_src, avatar_bytes).await.unwrap();
t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap()))
.await?;
let avatar_blob = t.get_config(Config::Selfavatar).await?.unwrap();
assert!(avatar_blob.ends_with(".jpg"));
let scaled_avatar_size = fs::metadata(&avatar_blob).await.unwrap().len();
assert!(
scaled_avatar_size < avatar_bytes.len() as u64,
"Animated WebP avatar must be recoded"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_and_deduplicate() -> Result<()> {
let t = TestContext::new().await;
+9 -14
View File
@@ -2924,7 +2924,9 @@ async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Ve
);
}
msg.subject.clone_from(&side_effects.subject);
if let Some(ref side_effects) = side_effects {
msg.subject.clone_from(&side_effects.subject);
}
if is_encrypted {
msg.param.set_int(Param::GuaranteeE2ee, 1);
} else {
@@ -2966,13 +2968,13 @@ async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Ve
now,
msg.id,
&queued_pre_msg,
Some(&pre_side_effects),
pre_side_effects.as_ref(),
)
.context("Failed to enqueue pre-message")?;
row_ids.push(row_id)
}
row_ids.push(
enqueue_mail(transaction, now, msg.id, &queued_msg, Some(&side_effects))
enqueue_mail(transaction, now, msg.id, &queued_msg, side_effects.as_ref())
.context("Failed to enqueue message")?,
);
Ok(row_ids)
@@ -3637,7 +3639,7 @@ pub(crate) async fn create_out_broadcast_ext(
)?;
ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}");
let mut params: Params = Params::new();
params.update_timestamp(Param::GroupNameTimestamp, time());
params.update_timestamp(Param::GroupNameTimestamp, time())?;
t.execute(
"INSERT INTO chats
@@ -4793,14 +4795,12 @@ pub(crate) async fn get_chat_id_by_grpid(
///
/// Optional `label` can be provided to ensure that message is added only once.
/// If `important` is true, a notification will be sent.
/// `timestamp_sent` is the time shown on the message; it does not affect ordering.
#[expect(clippy::arithmetic_side_effects)]
pub async fn add_device_msg_with_importance(
context: &Context,
label: Option<&str>,
msg: Option<&mut Message>,
important: bool,
timestamp_sent: i64,
) -> Result<MsgId> {
ensure!(
label.is_some() || msg.is_some(),
@@ -4820,10 +4820,11 @@ pub async fn add_device_msg_with_importance(
chat_id = ChatId::get_for_contact(context, ContactId::DEVICE).await?;
let rfc724_mid = create_outgoing_rfc724_mid();
let timestamp_sent = time();
// makes sure, the added message is the last one,
// even if the date is wrong (useful esp. when warning about bad dates)
msg.timestamp_sort = time();
msg.timestamp_sort = timestamp_sent;
if let Some(last_msg_time) = chat_id.get_timestamp(context).await?
&& msg.timestamp_sort <= last_msg_time
{
@@ -4891,7 +4892,7 @@ pub async fn add_device_msg(
label: Option<&str>,
msg: Option<&mut Message>,
) -> Result<MsgId> {
add_device_msg_with_importance(context, label, msg, false, time()).await
add_device_msg_with_importance(context, label, msg, false).await
}
/// Returns true if device message with a given label was ever added to the device chat.
@@ -4919,12 +4920,6 @@ pub(crate) async fn delete_and_reset_all_device_msgs(context: &Context) -> Resul
.execute("DELETE FROM msgs WHERE from_id=?;", (ContactId::DEVICE,))
.await?;
context.sql.execute("DELETE FROM devmsglabels;", ()).await?;
context
.set_config_internal(Config::BackupTransferMsgId, None)
.await?;
context
.set_config_internal(Config::BackupTransferTimestamp, None)
.await?;
// Insert labels for welcome messages to avoid them being re-added on reconfiguration.
context
+7 -14
View File
@@ -336,17 +336,6 @@ pub enum Config {
/// Timestamp of the last time housekeeping was run
LastHousekeeping,
/// ID of the device message added after backup transfer.
///
/// If the message does not exist on the first housekeeping
/// after `READD_BACKUP_TRANSFER_MSG_DELAY`, it is re-added.
BackupTransferMsgId,
/// Timestamp of the device message added after backup transfer.
///
/// Needed to check against `READD_BACKUP_TRANSFER_MSG_DELAY`.
BackupTransferTimestamp,
/// Timestamp of the last time accumulated broadcast channel reactions were sent
LastReactionsBroadcast,
@@ -478,6 +467,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 {
@@ -820,7 +813,7 @@ impl Context {
/// Set the given config to a boolean value.
pub async fn set_config_bool(&self, key: Config, value: bool) -> Result<()> {
self.set_config(key, Some(from_bool(value))).await?;
self.set_config(key, from_bool(value)).await?;
Ok(())
}
@@ -841,8 +834,8 @@ impl Context {
}
/// Returns a value for use in `Context::set_config_*()` for the given `bool`.
pub(crate) fn from_bool(val: bool) -> &'static str {
if val { "1" } else { "0" }
pub(crate) fn from_bool(val: bool) -> Option<&'static str> {
Some(if val { "1" } else { "0" })
}
pub(crate) fn bool_from_config(config: Option<&str>) -> bool {
+1 -1
View File
@@ -29,7 +29,7 @@ impl ServerParams {
if self.username.is_empty() {
vec![Self {
username: addr.to_string(),
..self
..self.clone()
}]
} else {
vec![self]
+4
View File
@@ -1030,6 +1030,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));
-2
View File
@@ -294,8 +294,6 @@ async fn test_get_info_completeness() {
// too sensitive or summarized in another item.
let skip_from_get_info = vec![
"addr",
"backup_transfer_msg_id",
"backup_transfer_timestamp",
"displayname",
"imap_certificate_checks",
"mail_server",
+2 -2
View File
@@ -1454,7 +1454,7 @@ impl Session {
/// or flags have been changed.
/// In this case we may want to skip next IDLE and do a round
/// of fetching new messages and synchronizing seen flags.
fn drain_unsolicited_responses(&self, context: &Context) -> bool {
fn drain_unsolicited_responses(&self, context: &Context) -> Result<bool> {
use UnsolicitedResponse::*;
use async_imap::imap_proto::Response;
use async_imap::imap_proto::ResponseCode;
@@ -1499,7 +1499,7 @@ impl Session {
}
}
}
should_refetch
Ok(should_refetch)
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ impl Session {
self.select_with_uidvalidity(context, folder).await?;
if self.drain_unsolicited_responses(context) {
if self.drain_unsolicited_responses(context)? {
self.new_mail = true;
}
-1
View File
@@ -30,7 +30,6 @@ use crate::tools::{
mod transfer;
use ::pgp::types::KeyDetails;
pub(crate) use transfer::maybe_readd_backup_transfer_msg;
pub use transfer::{BackupProvider, get_backup};
// Name of the database file in the backup.
+7 -97
View File
@@ -40,68 +40,21 @@ use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::EventType;
use crate::chat::add_device_msg_with_importance;
use crate::config::Config;
use crate::chat::add_device_msg;
use crate::context::Context;
use crate::imex::BlobDirContents;
use crate::key;
use crate::log::warn;
use crate::message::{Message, MsgId};
use crate::message::Message;
use crate::qr::Qr;
use crate::stock_str::backup_transfer_msg_body;
use crate::tools::{TempPathGuard, create_id, time};
use crate::tools::{TempPathGuard, create_id};
use super::{DBFILE_BACKUP_NAME, export_backup_stream, export_database, import_backup_stream};
/// ALPN protocol identifier for the backup transfer protocol.
const BACKUP_ALPN: &[u8] = b"/deltachat/backup";
/// Minimum time after a backup transfer before we check
/// whether the "second device added" device message still exists.
///
/// The check is done in the first housekeeping after this delay;
/// if the message was deleted until then, it is re-added with the original timestamp.
/// After the check, the user can delete the message as usual without it being re-added.
///
/// This makes it easier to spot unwanted "add second device" actions.
/// It is clear that this does not catch all eventualities, it is best-effort.
/// First line of defense is device locking and asking for secret explicitly before adding a second device.
const READD_BACKUP_TRANSFER_MSG_DELAY: i64 = 60 * 60;
/// Checks `Config::BackupTransferMsgId`
/// and re-adds the backup transfer device message if it does not exist and some time passed.
pub(crate) async fn maybe_readd_backup_transfer_msg(context: &Context) -> Result<()> {
let Some(msg_id) = context
.get_config_parsed::<u32>(Config::BackupTransferMsgId)
.await?
else {
return Ok(());
};
let timestamp = context
.get_config_i64(Config::BackupTransferTimestamp)
.await?;
if time() < timestamp.saturating_add(READD_BACKUP_TRANSFER_MSG_DELAY) {
return Ok(());
}
context
.set_config_internal(Config::BackupTransferMsgId, None)
.await?;
context
.set_config_internal(Config::BackupTransferTimestamp, None)
.await?;
if Message::load_from_db_optional(context, MsgId::new(msg_id))
.await?
.is_none()
{
let mut msg = Message::new_text(backup_transfer_msg_body(context));
add_device_msg_with_importance(context, None, Some(&mut msg), false, timestamp).await?;
}
Ok(())
}
/// Provide or send a backup of this device.
///
/// This creates a backup of the current device and starts a service which offers another
@@ -259,23 +212,8 @@ impl BackupProvider {
info!(context, "Received backup reception acknowledgement.");
context.emit_event(EventType::ImexProgress(1000));
let timestamp = time();
let mut msg = Message::new_text(backup_transfer_msg_body(&context));
let msg_id =
add_device_msg_with_importance(&context, None, Some(&mut msg), false, timestamp)
.await?;
context
.set_config_internal(
Config::BackupTransferTimestamp,
Some(&timestamp.to_string()),
)
.await?;
context
.set_config_internal(
Config::BackupTransferMsgId,
Some(&msg_id.to_u32().to_string()),
)
.await?;
add_device_msg(&context, None, Some(&mut msg)).await?;
Ok(())
}
@@ -455,17 +393,14 @@ pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
mod tests {
use std::time::Duration;
use crate::chat::{ChatId, ChatItem, get_chat_msgs, send_msg};
use crate::contact::ContactId;
use crate::message::{Viewtype, delete_msgs};
use crate::sql::housekeeping;
use crate::chat::{ChatItem, get_chat_msgs, send_msg};
use crate::message::Viewtype;
use crate::test_utils::TestContextManager;
use crate::tools::SystemTime;
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_receive() -> Result<()> {
async fn test_send_receive() {
let mut tcm = TestContextManager::new();
// Create first device.
@@ -537,31 +472,6 @@ mod tests {
.get_matching(|ev| matches!(ev, EventType::ImexProgress(1000)))
.await;
}
// When deleting the backup transfer message on the sending device,
// it is re-added once on housekeeping,
// but not within the first hour after the transfer.
let device_chat_id = ChatId::get_for_contact(&ctx0, ContactId::DEVICE).await?;
let original_msg = ctx0.get_last_msg_in(device_chat_id).await;
assert_eq!(original_msg.text, backup_transfer_msg_body(&ctx0));
delete_msgs(&ctx0, &[original_msg.id]).await?;
housekeeping(&ctx0).await?;
assert!(get_chat_msgs(&ctx0, device_chat_id).await?.is_empty()); // re-adding not done in first hour
SystemTime::shift(Duration::from_secs(60 * 60));
housekeeping(&ctx0).await?;
let readded_msg = ctx0.get_last_msg_in(device_chat_id).await;
assert_ne!(readded_msg.id, original_msg.id);
assert_eq!(readded_msg.get_text(), original_msg.get_text());
assert_eq!(readded_msg.get_timestamp(), original_msg.get_timestamp());
assert!(readded_msg.get_sort_timestamp() > original_msg.get_sort_timestamp());
delete_msgs(&ctx0, &[readded_msg.id]).await?;
housekeeping(&ctx0).await?;
let device_msgs = get_chat_msgs(&ctx0, device_chat_id).await?;
assert!(device_msgs.is_empty()); // re-adding is done only once
Ok(())
}
/// Tests that trying to accidentally overwrite a profile
+15 -10
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;
@@ -98,8 +100,7 @@ pub trait DcKey: Serialize + Deserializable + Clone {
// Because we write to a Vec<u8> the io::Write impls never
// fail and we can hide this error.
let mut buf = Vec::new();
self.to_writer(&mut buf)
.expect("Writing to Vec<u8> cannot fail");
self.to_writer(&mut buf).unwrap();
buf
}
@@ -125,14 +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.");
// Make sure timestamp of created signatures
// is not in the past compared to the primary key timestamp.
let timestamp = std::cmp::max(
@@ -156,7 +154,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(),
))?,
@@ -305,7 +303,7 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
let addr = context.get_primary_self_addr().await?;
let all_addrs = context.get_self_addrs().await?.join(",");
let signed_public_key =
secret_key_to_public_key(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))
@@ -481,9 +479,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!(
+1 -2
View File
@@ -15,8 +15,7 @@
clippy::explicit_iter_loop,
clippy::explicit_into_iter_loop,
clippy::cloned_instead_of_copied,
clippy::manual_is_variant_and,
clippy::unnecessary_wraps
clippy::manual_is_variant_and
)]
#![cfg_attr(not(test), warn(clippy::arithmetic_side_effects))]
#![cfg_attr(not(test), forbid(clippy::indexing_slicing))]
+8 -13
View File
@@ -580,7 +580,7 @@ impl Message {
if let Some(msg) = &mut msg {
msg.additional_text =
Self::get_additional_text(context, msg.download_state, &msg.param);
Self::get_additional_text(context, msg.download_state, &msg.param)?;
}
Ok(msg)
@@ -618,7 +618,7 @@ impl Message {
context: &Context,
download_state: DownloadState,
param: &Params,
) -> String {
) -> Result<String> {
if download_state != DownloadState::Done {
let file_size = param
.get(Param::PostMessageFileBytes)
@@ -635,14 +635,14 @@ impl Message {
.unwrap_or("?".to_owned());
return match viewtype {
Viewtype::File => format!(" [{file_name} – {file_size}]"),
Viewtype::File => Ok(format!(" [{file_name} – {file_size}]")),
_ => {
let translated_viewtype = viewtype.to_locale_string(context);
format!(" [{translated_viewtype} – {file_size}]")
Ok(format!(" [{translated_viewtype} – {file_size}]"))
}
};
}
String::new()
Ok(String::new())
}
/// Returns the MIME type of an attached file if it exists.
@@ -2017,19 +2017,14 @@ pub(crate) async fn set_msg_failed(
/// Inserts a tombstone into `msgs` table
/// to prevent downloading the same message in the future.
/// With `on_server`, copies seen on IMAP later are deleted on the server.
///
/// Returns tombstone database row ID.
pub(crate) async fn insert_tombstone(
context: &Context,
rfc724_mid: &str,
on_server: bool,
) -> Result<MsgId> {
pub(crate) async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId> {
let row_id = context
.sql
.insert(
"INSERT INTO msgs(rfc724_mid, chat_id, deleted) VALUES (?,?,?)",
(rfc724_mid, ChatId::TRASH, on_server),
"INSERT INTO msgs(rfc724_mid, chat_id) VALUES (?,?)",
(rfc724_mid, ChatId::TRASH),
)
.await?;
let msg_id = MsgId::new(u32::try_from(row_id)?);
+3 -1
View File
@@ -784,7 +784,7 @@ async fn test_get_existing_msg_ids() -> Result<()> {
}
#[test]
fn test_can_fail() {
fn test_can_fail() -> Result<()> {
use MessageState::*;
// states that are not allowed to transition to OutFailed
@@ -799,4 +799,6 @@ fn test_can_fail() {
assert!(OutPending.can_fail());
assert!(OutDelivered.can_fail());
assert!(OutFailed.can_fail());
Ok(())
}
+417 -340
View File
@@ -26,6 +26,7 @@ use crate::download::PostMsgMetadata;
use crate::ensure_and_debug_assert;
use crate::ephemeral::Timer as EphemeralTimer;
use crate::headerdef::HeaderDef;
use crate::key;
use crate::key::{DcKey, SignedPublicKey, SignedSecretKey, load_self_public_key, self_fingerprint};
use crate::location;
use crate::log::warn;
@@ -56,6 +57,19 @@ use crate::webxdc::StatusUpdateSerial;
/// and divide by 4/3 to account for base64 encoding.
pub const RECOMMENDED_FILE_SIZE: u64 = (30 - 1) * 1024 * 1024 / 4 * 3;
#[derive(Debug, Clone)]
#[expect(clippy::large_enum_variant)]
pub enum Loaded {
Message {
chat: Chat,
msg: Message,
},
Mdn {
rfc724_mid: String,
additional_msg_ids: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum PreMessageMode {
/// adds the Chat-Is-Post-Message header in unprotected part
@@ -171,10 +185,7 @@ pub struct MimeFactory {
member_timestamps: Vec<i64>,
timestamp: i64,
chat: Chat,
msg: Message,
loaded: Loaded,
in_reply_to: String,
/// List of Message-IDs for `References` header.
@@ -320,9 +331,10 @@ pub(crate) fn render_queued_mail(
outer_headers.extend(b"MIME-Version: 1.0\r\n");
if should_attach_pubkey {
let public_key = crate::pgp::minimize_autocrypt_certificate(public_key);
let aheader = Aheader {
addr: from_addr,
public_key: public_key.clone(),
public_key,
prefer_encrypt: EncryptPreference::Mutual,
};
let autocrypt_header = mail_builder::headers::raw::Raw::new(aheader.to_string());
@@ -442,8 +454,8 @@ pub(crate) async fn render_queued_mail_with_context(
context: &Context,
) -> Result<RenderedEmail> {
let from_addr = context.get_primary_self_addr().await?;
let public_key = crate::key::load_self_public_key(context).await?;
let secret_key = crate::key::load_self_secret_key(context).await?;
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let rendered_mail = render_queued_mail(queued_mail, &public_key, &secret_key, from_addr)?;
Ok(rendered_mail)
@@ -500,6 +512,11 @@ impl MimeFactory {
let mut member_fingerprints = Vec::new();
let mut member_timestamps = Vec::new();
let mut recipient_ids = HashSet::new();
let req_mdn = !chat.is_self_talk()
&& !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
&& context.should_request_mdns().await?;
let self_fingerprint = self_fingerprint(context).await?;
let encryption = if chat.is_self_talk() {
@@ -780,12 +797,6 @@ impl MimeFactory {
}
};
let req_mdn = encryption.is_encrypted()
&& !chat.is_self_talk()
&& !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
&& context.should_request_mdns().await?;
let (in_reply_to, references) = context
.sql
.query_row(
@@ -839,8 +850,7 @@ impl MimeFactory {
member_fingerprints,
member_timestamps,
timestamp: msg.timestamp_sort,
chat,
msg,
loaded: Loaded::Message { msg, chat },
in_reply_to,
references,
req_mdn,
@@ -851,6 +861,62 @@ impl MimeFactory {
Ok(factory)
}
pub async fn from_mdn(
context: &Context,
from_id: ContactId,
rfc724_mid: String,
additional_msg_ids: Vec<String>,
) -> Result<MimeFactory> {
let contact = Contact::get_by_id(context, from_id).await?;
let from_addr = context.get_primary_self_addr().await?;
let timestamp = time();
let addr = contact.get_addr().to_string();
let mut recipients = vec![addr.clone()];
let encryption = if from_id == ContactId::SELF {
Encryption::Asymmetric {
encryption_pubkeys: Vec::new(),
}
} else if contact.is_key_contact() {
let encryption_pubkeys = if let Some(key) = contact.public_key(context).await? {
recipients = relay_addrs(&key, &addr);
vec![(addr.clone(), key)]
} else {
Vec::new()
};
Encryption::Asymmetric { encryption_pubkeys }
} else {
Encryption::No
};
let res = MimeFactory {
from_addr,
from_displayname: "".to_string(),
sender_displayname: None,
selfstatus: "".to_string(),
recipients,
encryption,
to: vec![("".to_string(), contact.get_addr().to_string())],
past_members: vec![],
member_fingerprints: vec![],
member_timestamps: vec![],
timestamp,
loaded: Loaded::Mdn {
rfc724_mid,
additional_msg_ids,
},
in_reply_to: String::default(),
references: Vec::new(),
req_mdn: false,
attach_selfavatar: false,
webxdc_topic: None,
pre_message_mode: PreMessageMode::None,
};
Ok(res)
}
/// Returns whether own Autocrypt key should be attached to this MDN
/// and if so, records the attachment.
///
@@ -860,15 +926,15 @@ impl MimeFactory {
/// so that contacts we only read messages from
/// still learn our current key and relay list
/// and will likely re-gossip it to group chats.
async fn update_mdn_pubkey_attachment(
context: &Context,
encryption_pubkeys: &[SignedPublicKey],
) -> Result<bool> {
async fn update_mdn_pubkey_attachment(&self, context: &Context) -> Result<bool> {
let Encryption::Asymmetric { encryption_pubkeys } = &self.encryption else {
return Ok(false);
};
debug_assert!(
encryption_pubkeys.len() <= 1,
"MDNs have at most one recipient key; own key is only added at encryption time"
);
let [ref key] = encryption_pubkeys[..] else {
let [(_, ref key)] = encryption_pubkeys[..] else {
return Ok(false);
};
let fingerprint = key.dc_fingerprint().hex();
@@ -950,69 +1016,80 @@ impl MimeFactory {
}
fn grpimage(&self) -> Option<String> {
let cmd = self.msg.param.get_cmd();
match &self.loaded {
Loaded::Message { chat, msg } => {
let cmd = msg.param.get_cmd();
match cmd {
SystemMessage::MemberAddedToGroup => {
return self.chat.param.get(Param::ProfileImage).map(Into::into);
match cmd {
SystemMessage::MemberAddedToGroup => {
return chat.param.get(Param::ProfileImage).map(Into::into);
}
SystemMessage::GroupImageChanged => {
return msg.param.get(Param::Arg).map(Into::into);
}
_ => {}
}
if msg
.param
.get_bool(Param::AttachChatAvatarAndDescription)
.unwrap_or_default()
{
return chat.param.get(Param::ProfileImage).map(Into::into);
}
None
}
SystemMessage::GroupImageChanged => {
return self.msg.param.get(Param::Arg).map(Into::into);
}
_ => {}
Loaded::Mdn { .. } => None,
}
if self
.msg
.param
.get_bool(Param::AttachChatAvatarAndDescription)
.unwrap_or_default()
{
return self.chat.param.get(Param::ProfileImage).map(Into::into);
}
None
}
async fn subject_str(&self, context: &Context) -> Result<String> {
let quoted_msg_subject = self.msg.quoted_message(context).await?.map(|m| m.subject);
let subject = match &self.loaded {
Loaded::Message { chat, msg } => {
let quoted_msg_subject = msg.quoted_message(context).await?.map(|m| m.subject);
if !self.msg.subject.is_empty() {
return Ok(self.msg.subject.clone());
}
if !msg.subject.is_empty() {
return Ok(msg.subject.clone());
}
if (self.chat.typ == Chattype::Group || self.chat.typ == Chattype::OutBroadcast)
&& quoted_msg_subject.is_none_or_empty()
{
let re = if self.in_reply_to.is_empty() {
""
} else {
"Re: "
};
return Ok(format!("{}{}", re, self.chat.name));
}
if (chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast)
&& quoted_msg_subject.is_none_or_empty()
{
let re = if self.in_reply_to.is_empty() {
""
} else {
"Re: "
};
return Ok(format!("{}{}", re, chat.name));
}
let parent_subject = if quoted_msg_subject.is_none_or_empty() {
self.chat.param.get(Param::LastSubject)
} else {
quoted_msg_subject.as_deref()
let parent_subject = if quoted_msg_subject.is_none_or_empty() {
chat.param.get(Param::LastSubject)
} else {
quoted_msg_subject.as_deref()
};
if let Some(last_subject) = parent_subject {
return Ok(format!("Re: {}", remove_subject_prefix(last_subject)));
}
let self_name = match Self::should_attach_profile_data(msg) {
true => context.get_config(Config::Displayname).await?,
false => None,
};
let self_name = &match self_name {
Some(name) => name,
None => context
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default(),
};
stock_str::subject_for_new_contact(context, self_name)
}
Loaded::Mdn { .. } => "Receipt Notification".to_string(), // untranslated to no reveal sender's language
};
if let Some(last_subject) = parent_subject {
return Ok(format!("Re: {}", remove_subject_prefix(last_subject)));
}
let self_name = match Self::should_attach_profile_data(&self.msg) {
true => context.get_config(Config::Displayname).await?,
false => None,
};
let self_name = &match self_name {
Some(name) => name,
None => context
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default(),
};
Ok(stock_str::subject_for_new_contact(context, self_name))
Ok(subject)
}
pub fn recipients(&self) -> Vec<String> {
@@ -1089,10 +1166,10 @@ impl MimeFactory {
));
}
if self.chat.typ == Chattype::Group {
if !self.member_timestamps.is_empty()
&& !self.chat.member_list_is_stale(context).await?
{
if let Loaded::Message { chat, .. } = &self.loaded
&& chat.typ == Chattype::Group
{
if !self.member_timestamps.is_empty() && !chat.member_list_is_stale(context).await? {
headers.push((
"Chat-Group-Member-Timestamps",
mail_builder::headers::raw::Raw::new(
@@ -1144,25 +1221,29 @@ impl MimeFactory {
}
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
if context.get_config_bool(Config::Bot).await? {
if let Loaded::Mdn { .. } = self.loaded {
headers.push((
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
));
} else if context.get_config_bool(Config::Bot).await? {
headers.push((
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
));
}
if self.chat.typ == Chattype::OutBroadcast || self.chat.typ == Chattype::InBroadcast {
if let Loaded::Message { msg, chat } = &self.loaded
&& (chat.typ == Chattype::OutBroadcast || chat.typ == Chattype::InBroadcast)
{
headers.push((
"Chat-List-ID",
mail_builder::headers::text::Text::new(format!(
"{} <{}>",
self.chat.name, self.chat.grpid
))
.into(),
mail_builder::headers::text::Text::new(format!("{} <{}>", chat.name, chat.grpid))
.into(),
));
if self.msg.param.get_cmd() == SystemMessage::MemberAddedToGroup
&& let Some(secret) = self.msg.param.get(PARAM_BROADCAST_SECRET)
if msg.param.get_cmd() == SystemMessage::MemberAddedToGroup
&& let Some(secret) = msg.param.get(PARAM_BROADCAST_SECRET)
{
headers.push((
"Chat-Broadcast-Secret",
@@ -1171,18 +1252,22 @@ impl MimeFactory {
}
}
if let Some(original_rfc724_mid) = self.msg.param.get(Param::TextEditFor) {
headers.push((
"Chat-Edit",
mail_builder::headers::message_id::MessageId::new(original_rfc724_mid.to_string())
if let Loaded::Message { msg, .. } = &self.loaded {
if let Some(original_rfc724_mid) = msg.param.get(Param::TextEditFor) {
headers.push((
"Chat-Edit",
mail_builder::headers::message_id::MessageId::new(
original_rfc724_mid.to_string(),
)
.into(),
));
} else if let Some(rfc724_mid_list) = self.msg.param.get(Param::DeleteRequestFor) {
headers.push((
"Chat-Delete",
mail_builder::headers::message_id::MessageId::new(rfc724_mid_list.to_string())
.into(),
));
));
} else if let Some(rfc724_mid_list) = msg.param.get(Param::DeleteRequestFor) {
headers.push((
"Chat-Delete",
mail_builder::headers::message_id::MessageId::new(rfc724_mid_list.to_string())
.into(),
));
}
}
headers.push((
@@ -1219,12 +1304,14 @@ impl MimeFactory {
// Add ephemeral timer for non-MDN messages.
// For MDNs it does not matter because they are not visible
// and ignored by the receiver.
let ephemeral_timer = self.msg.chat_id.get_ephemeral_timer(context).await?;
if let EphemeralTimer::Enabled { duration } = ephemeral_timer {
headers.push((
"Ephemeral-Timer",
mail_builder::headers::raw::Raw::new(duration.to_string()).into(),
));
if let Loaded::Message { msg, .. } = &self.loaded {
let ephemeral_timer = msg.chat_id.get_ephemeral_timer(context).await?;
if let EphemeralTimer::Enabled { duration } = ephemeral_timer {
headers.push((
"Ephemeral-Timer",
mail_builder::headers::raw::Raw::new(duration.to_string()).into(),
));
}
}
Ok(headers)
@@ -1232,14 +1319,19 @@ impl MimeFactory {
/// Helper function render the messages that are not queued.
///
/// Used only for tests.
#[cfg(test)]
pub async fn render(self, context: &Context) -> Result<RenderedEmail> {
/// Used for MDNs because they are fully rendered and sent in one go,
/// rather than first creating a [`QueuedMail`] and sending it later.
pub async fn render(self, context: &Context, from_addr: &str) -> Result<RenderedEmail> {
// Does not matter, we are not going to return the QueuedMail.
let bcc_self = false;
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let (queued_mail, _side_effects) =
Box::pin(self.into_queued_mail(context, bcc_self)).await?;
render_queued_mail_with_context(queued_mail, context).await
let rendered_mail =
render_queued_mail(queued_mail, &public_key, &secret_key, from_addr.to_string())?;
Ok(rendered_mail)
}
/// Consumes a `MimeFactory` and renders it into a message which is then stored in
@@ -1250,15 +1342,18 @@ impl MimeFactory {
context: &Context,
bcc_self: bool,
) -> Result<ToBeQueuedMail> {
let rfc724_mid = match &self.pre_message_mode {
PreMessageMode::Pre { .. } => {
if self.msg.pre_rfc724_mid.is_empty() {
create_outgoing_rfc724_mid()
} else {
self.msg.pre_rfc724_mid.clone()
let rfc724_mid = match &self.loaded {
Loaded::Message { msg, .. } => match &self.pre_message_mode {
PreMessageMode::Pre { .. } => {
if msg.pre_rfc724_mid.is_empty() {
create_outgoing_rfc724_mid()
} else {
msg.pre_rfc724_mid.clone()
}
}
}
_ => self.msg.rfc724_mid.clone(),
_ => msg.rfc724_mid.clone(),
},
Loaded::Mdn { .. } => create_outgoing_rfc724_mid(),
};
let subject_str = self.subject_str(context).await?;
@@ -1268,44 +1363,61 @@ impl MimeFactory {
let is_encrypted = self.will_be_encrypted();
let RenderedMessage {
main_part,
mut parts,
last_added_location_timestamp,
avatar_is_attached,
sync_ids_to_delete,
} = self
.render_message(context, &mut headers, &grpimage, is_encrypted)
.await?;
let side_effects: Option<QueueSideEffects>;
let side_effects = QueueSideEffects {
chat_id: self.msg.chat_id,
avatar_is_attached,
sync_ids_to_delete,
last_added_location_timestamp,
subject: subject_str,
};
let message: MimePart<'static> = match &self.loaded {
Loaded::Message { msg, .. } => {
let msg = msg.clone();
let RenderedMessage {
main_part,
mut parts,
last_added_location_timestamp,
avatar_is_attached,
sync_ids_to_delete,
} = self
.render_message(context, &mut headers, &grpimage, is_encrypted)
.await?;
let message: MimePart<'static> = if parts.is_empty() {
// Single part, render as regular message.
main_part
} else {
parts.insert(0, main_part);
side_effects = Some(QueueSideEffects {
chat_id: msg.chat_id,
avatar_is_attached,
sync_ids_to_delete,
last_added_location_timestamp,
subject: subject_str,
});
// Multiple parts, render as multipart.
if self.msg.param.get_cmd() == SystemMessage::MultiDeviceSync {
MimePart::new("multipart/report; report-type=multi-device-sync", parts)
} else if self.msg.param.get_cmd() == SystemMessage::WebxdcStatusUpdate {
MimePart::new("multipart/report; report-type=status-update", parts)
} else {
MimePart::new("multipart/mixed", parts)
if parts.is_empty() {
// Single part, render as regular message.
main_part
} else {
parts.insert(0, main_part);
// Multiple parts, render as multipart.
if msg.param.get_cmd() == SystemMessage::MultiDeviceSync {
MimePart::new("multipart/report; report-type=multi-device-sync", parts)
} else if msg.param.get_cmd() == SystemMessage::WebxdcStatusUpdate {
MimePart::new("multipart/report; report-type=status-update", parts)
} else {
MimePart::new("multipart/mixed", parts)
}
}
}
Loaded::Mdn { .. } => {
side_effects = None;
self.render_mdn()?
}
};
let should_attach_pubkey = true;
let should_attach_pubkey = match &self.loaded {
Loaded::Message { .. } => true,
Loaded::Mdn { .. } => self.update_mdn_pubkey_attachment(context).await?,
};
let is_post_message = self.pre_message_mode == PreMessageMode::Post;
let is_securejoin_message = self.msg.param.get_cmd() == SystemMessage::SecurejoinMessage;
let is_securejoin_message = match &self.loaded {
Loaded::Message { msg, .. } => msg.param.get_cmd() == SystemMessage::SecurejoinMessage,
Loaded::Mdn { .. } => false,
};
// Disable compression for SecureJoin to ensure
// there are no compression side channels
@@ -1323,70 +1435,81 @@ impl MimeFactory {
let gossip_period = context.get_config_i64(Config::GossipPeriod).await?;
let now = time();
if !should_hide_recipients(&self.msg, &self.chat) {
for (addr, key) in encryption_pubkeys {
let fingerprint = key.dc_fingerprint().hex();
let cmd = self.msg.param.get_cmd();
if is_post_message {
continue;
}
match &self.loaded {
Loaded::Message { chat, msg } => {
if !should_hide_recipients(msg, chat) {
for (addr, key) in encryption_pubkeys {
let fingerprint = key.dc_fingerprint().hex();
let cmd = msg.param.get_cmd();
if is_post_message {
continue;
}
let should_do_gossip = cmd == SystemMessage::MemberAddedToGroup
|| cmd == SystemMessage::SecurejoinMessage
|| multiple_recipients && {
let gossiped_timestamp: Option<i64> = context
.sql
.query_get_value(
"SELECT timestamp
let should_do_gossip = cmd == SystemMessage::MemberAddedToGroup
|| cmd == SystemMessage::SecurejoinMessage
|| multiple_recipients && {
let gossiped_timestamp: Option<i64> = context
.sql
.query_get_value(
"SELECT timestamp
FROM gossip_timestamp
WHERE chat_id=? AND fingerprint=?",
(self.chat.id, &fingerprint),
)
.await?;
(chat.id, &fingerprint),
)
.await?;
// `gossip_period == 0` is a special case for testing,
// enabling gossip in every message.
//
// If current time is in the past compared to
// `gossiped_timestamp`, we also gossip because
// either the `gossiped_timestamp` or clock is wrong.
gossip_period == 0
|| gossiped_timestamp
.is_none_or(|ts| now >= ts + gossip_period || now < ts)
};
// `gossip_period == 0` is a special case for testing,
// enabling gossip in every message.
//
// If current time is in the past compared to
// `gossiped_timestamp`, we also gossip because
// either the `gossiped_timestamp` or clock is wrong.
gossip_period == 0
|| gossiped_timestamp
.is_none_or(|ts| now >= ts + gossip_period || now < ts)
};
if !should_do_gossip {
continue;
}
if !should_do_gossip {
continue;
}
let header = Aheader {
addr: addr.clone(),
public_key: key.clone(),
// Autocrypt 1.1.0 specification says that
// `prefer-encrypt` attribute SHOULD NOT be included.
prefer_encrypt: EncryptPreference::NoPreference,
}
.to_string();
let public_key = crate::pgp::minimize_autocrypt_certificate(key);
headers.push((
"Autocrypt-Gossip",
mail_builder::headers::raw::Raw::new(header).into(),
));
let header = Aheader {
addr: addr.clone(),
public_key,
// Autocrypt 1.1.0 specification says that
// `prefer-encrypt` attribute SHOULD NOT be included.
prefer_encrypt: EncryptPreference::NoPreference,
}
.to_string();
context
.sql
.execute(
"INSERT INTO gossip_timestamp (chat_id, fingerprint, timestamp)
headers.push((
"Autocrypt-Gossip",
mail_builder::headers::raw::Raw::new(header).into(),
));
context
.sql
.execute(
"INSERT INTO gossip_timestamp (chat_id, fingerprint, timestamp)
VALUES (?, ?, ?)
ON CONFLICT (chat_id, fingerprint)
DO UPDATE SET timestamp=excluded.timestamp",
(self.chat.id, &fingerprint, now),
)
.await?;
(chat.id, &fingerprint, now),
)
.await?;
}
}
}
Loaded::Mdn { .. } => {
// Never gossip in MDNs.
}
}
}
let is_encrypted = self.will_be_encrypted();
let display_name = if is_securejoin_message && !is_encrypted {
// Unencrypted securejoin messages should _not_ include the display name.
"".to_string()
@@ -1394,13 +1517,25 @@ impl MimeFactory {
self.from_displayname.clone()
};
let is_mdn = matches!(self.loaded, Loaded::Mdn { .. });
let should_sign = true;
let message = if is_encrypted {
add_headers_to_encrypted_part(message, headers)
} else if is_mdn {
// Never add outer multipart/mixed wrapper to MDN
// as multipart/report Content-Type is used to recognize MDNs
// by Delta Chat receiver and Chatmail servers
// allowing them to be unencrypted and not contain Autocrypt header
// without resetting Autocrypt encryption or triggering Chatmail filter
// that normally only allows encrypted mails.
message
} else {
// Unencrypted message.
let message = if self.msg.param.get_cmd() == SystemMessage::SecurejoinMessage
let message = if let Loaded::Message { msg, .. } = &self.loaded
&& msg.param.get_cmd() == SystemMessage::SecurejoinMessage
&& matches!(
self.msg.param.get(Param::Arg),
msg.param.get(Param::Arg),
Some("vc-request") | Some("vg-request")
) {
// Workaround for legacy SecureJoin {vc,vg}-request messages.
@@ -1413,7 +1548,12 @@ impl MimeFactory {
message
};
add_headers_to_part(message, headers)
headers.iter().fold(message, |message, (header, value)| {
debug_assert_ne!(*header, "from");
debug_assert_ne!(*header, "message-id");
debug_assert_ne!(*header, "autocrypt");
message.header(*header, value.clone())
})
};
let raw_message = part_to_bytes(message);
let recipients = self.recipients();
@@ -1424,7 +1564,7 @@ impl MimeFactory {
display_name,
encryption: self.encryption.into_queued_encryption(),
should_attach_pubkey,
should_sign: true,
should_sign,
should_compress,
recipients,
sent_to: Vec::new(),
@@ -1435,10 +1575,14 @@ impl MimeFactory {
/// Returns MIME part with a `message.kml` attachment.
fn get_message_kml_part(&self) -> Option<MimePart<'static>> {
let latitude = self.msg.param.get_float(Param::SetLatitude)?;
let longitude = self.msg.param.get_float(Param::SetLongitude)?;
let Loaded::Message { msg, .. } = &self.loaded else {
return None;
};
let kml_file = location::get_message_kml(self.msg.timestamp_sort, latitude, longitude);
let latitude = msg.param.get_float(Param::SetLatitude)?;
let longitude = msg.param.get_float(Param::SetLongitude)?;
let kml_file = location::get_message_kml(msg.timestamp_sort, latitude, longitude);
let part = MimePart::new("application/vnd.google-earth.kml+xml", kml_file)
.attachment("message.kml");
Some(part)
@@ -1450,8 +1594,12 @@ impl MimeFactory {
&self,
context: &Context,
) -> Result<Option<(MimePart<'static>, i64)>> {
let Loaded::Message { msg, .. } = &self.loaded else {
return Ok(None);
};
let Some((kml_content, last_added_location_timestamp)) =
location::get_kml(context, self.msg.chat_id).await?
location::get_kml(context, msg.chat_id).await?
else {
return Ok(None);
};
@@ -1468,8 +1616,11 @@ impl MimeFactory {
grpimage: &Option<String>,
is_encrypted: bool,
) -> Result<RenderedMessage> {
let chat = self.chat.clone();
let msg = self.msg.clone();
let Loaded::Message { chat, msg } = &self.loaded else {
bail!("Attempt to render MDN as a message");
};
let chat = chat.clone();
let msg = msg.clone();
let command = msg.param.get_cmd();
let mut placeholdertext = None;
@@ -2005,6 +2156,59 @@ impl MimeFactory {
})
}
/// Render an MDN
fn render_mdn(&mut self) -> Result<MimePart<'static>> {
// RFC 6522, this also requires the `report-type` parameter which is equal
// to the MIME subtype of the second body part of the multipart/report
let Loaded::Mdn {
rfc724_mid,
additional_msg_ids,
} = &self.loaded
else {
bail!("Attempt to render a message as MDN");
};
// first body part: always human-readable, always REQUIRED by RFC 6522.
// untranslated to no reveal sender's language.
// moreover, translations in unknown languages are confusing, and clients may not display them at all
let text_part = MimePart::new("text/plain", "This is a receipt notification.");
let mut message = MimePart::new(
"multipart/report; report-type=disposition-notification",
vec![text_part],
);
// second body part: machine-readable, always REQUIRED by RFC 6522
//
// We do not include the Final-Recipient field.
// According to <https://datatracker.ietf.org/doc/html/rfc8098#section-3.2.4>
// it MUST be present and be the address on which original message was received,
// but practically it is not going to be used.
let message_text2 = format!(
"Original-Message-ID: <{rfc724_mid}>\r\n\
Disposition: manual-action/MDN-sent-automatically; displayed\r\n",
);
let extension_fields = if additional_msg_ids.is_empty() {
"".to_string()
} else {
"Additional-Message-IDs: ".to_string()
+ &additional_msg_ids
.iter()
.map(|mid| render_rfc724_mid(mid))
.collect::<Vec<String>>()
.join(" ")
+ "\r\n"
};
message.add_part(MimePart::new(
"message/disposition-notification",
message_text2 + &extension_fields,
));
Ok(message)
}
pub fn will_be_encrypted(&self) -> bool {
self.encryption.is_encrypted()
}
@@ -2034,26 +2238,20 @@ pub(crate) fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> {
)
}
fn add_headers_to_part(
message: MimePart<'static>,
headers: Vec<(&'static str, HeaderType<'static>)>,
) -> MimePart<'static> {
headers
.into_iter()
.fold(message, |message, (header, value)| {
debug_assert_ne!(header, "from");
debug_assert_ne!(header, "message-id");
debug_assert_ne!(header, "autocrypt");
message.header(header, value)
})
}
fn add_headers_to_encrypted_part(
message: MimePart<'static>,
protected_headers: Vec<(&'static str, HeaderType<'static>)>,
) -> MimePart<'static> {
// Store protected headers in the inner message.
let mut message = add_headers_to_part(message, protected_headers);
let mut message: MimePart<'static> =
protected_headers
.into_iter()
.fold(message, |message, (header, value)| {
debug_assert_ne!(header, "from");
debug_assert_ne!(header, "message-id");
debug_assert_ne!(header, "autocrypt");
message.header(header, value)
});
// Set the appropriate Content-Type for the inner message
for (h, v) in &mut message.headers {
@@ -2241,127 +2439,6 @@ pub(crate) async fn symm_encrypted_securejoin_message(
Ok(queued_mail)
}
/// Returns an MDN body.
fn mdn_body(rfc724_mid: &str, additional_msg_ids: Vec<String>) -> MimePart<'static> {
// RFC 6522, this also requires the `report-type` parameter which is equal
// to the MIME subtype of the second body part of the multipart/report
// first body part: always human-readable, always REQUIRED by RFC 6522.
// untranslated to no reveal sender's language.
// moreover, translations in unknown languages are confusing, and clients may not display them at all
let text_part = MimePart::new("text/plain", "This is a receipt notification.");
// second body part: machine-readable, always REQUIRED by RFC 6522
//
// We do not include the Final-Recipient field.
// According to <https://datatracker.ietf.org/doc/html/rfc8098#section-3.2.4>
// it MUST be present and be the address on which original message was received,
// but practically it is not going to be used.
let message_text2 = format!(
"Original-Message-ID: <{rfc724_mid}>\r\n\
Disposition: manual-action/MDN-sent-automatically; displayed\r\n",
);
let extension_fields = if additional_msg_ids.is_empty() {
"".to_string()
} else {
"Additional-Message-IDs: ".to_string()
+ &additional_msg_ids
.iter()
.map(|mid| render_rfc724_mid(mid))
.collect::<Vec<String>>()
.join(" ")
+ "\r\n"
};
MimePart::new(
"multipart/report; report-type=disposition-notification",
vec![
text_part,
MimePart::new(
"message/disposition-notification",
message_text2 + &extension_fields,
),
],
)
}
pub(crate) async fn mdn(
context: &Context,
contact_id: ContactId,
rfc724_mid: &str,
additional_rfc724_mids: Vec<String>,
) -> Result<QueuedMail> {
let contact = Contact::get_by_id(context, contact_id).await?;
let timestamp = time();
let addr = contact.get_addr().to_string();
let recipients: Vec<String>;
let encryption_pubkeys = if contact_id == ContactId::SELF {
recipients = Vec::new();
Vec::new()
} else if let Some(key) = contact.public_key(context).await? {
recipients = relay_addrs(&key, &addr);
vec![key]
} else {
warn!(context, "Contact {contact_id} has no key, sending to self.");
// Encryption key for the contact is not available, sending MDN to self only.
recipients = Vec::new();
Vec::new()
};
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0)
.context("Failed to convert timestamp to DateTime")?
.to_rfc2822();
let headers: Vec<(&'static str, HeaderType<'static>)> = vec![
("Date", mail_builder::headers::raw::Raw::new(date).into()),
(
"To",
mail_builder::headers::address::Address::new_list(vec![Address::new_address(
None::<&'static str>,
contact.get_addr().to_string(),
)])
.into(),
),
// Subject is untranslated to not reveal sender's language.
(
"Subject",
mail_builder::headers::text::Text::new("Receipt Notification".to_string()).into(),
),
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
(
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
),
(
"Chat-Version",
mail_builder::headers::raw::Raw::new("1.0").into(),
),
];
let message = mdn_body(rfc724_mid, additional_rfc724_mids);
let should_attach_pubkey =
MimeFactory::update_mdn_pubkey_attachment(context, &encryption_pubkeys).await?;
let message = add_headers_to_encrypted_part(message, headers);
let raw_message = part_to_bytes(message);
let encryption = QueuedEncryption::Asymmetric { encryption_pubkeys };
let queued_mdn = QueuedMail {
raw_message,
rfc724_mid: create_outgoing_rfc724_mid(),
display_name: String::new(),
encryption,
should_attach_pubkey,
should_sign: true,
should_compress: true,
recipients,
sent_to: Vec::new(),
bcc_self,
};
Ok(queued_mdn)
}
/// Returns the body of a keyupdate message, shaped like a receipt notification.
///
/// The shape is what every core goes by, as a keyupdate carries no marker:
@@ -2369,7 +2446,7 @@ pub(crate) async fn mdn(
/// while a plain text body would end up in a contact request.
/// The report deliberately names no original message, see [`crate::keyupdate`].
fn keyupdate_body() -> MimePart<'static> {
// Human-readable first part as RFC 6522 requires.
// Human-readable first part as RFC 6522 requires, untranslated like in `render_mdn`.
let text_part = MimePart::new(
"text/plain",
"This message updates the sender's encryption key and relay list.",
+24 -46
View File
@@ -281,12 +281,8 @@ async fn test_subject_mdn() {
assert_eq!("Re: Hello, Bob", mf.subject_str(t).await.unwrap());
}
/// Tests that MDN for unencrypted message can be created without throwing an error.
///
/// We do not send unencrypted MDNs, but do not want SMTP loop to get stuck
/// if we somehow request the creation of unencrypted MDN.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mdn_create_unencrypted() -> Result<()> {
async fn test_mdn_create_encrypted() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
alice.allow_unencrypted().await?;
@@ -301,43 +297,21 @@ async fn test_mdn_create_unencrypted() -> Result<()> {
.await?;
bob.set_config_bool(Config::MdnsEnabled, true).await?;
// MDN for unencrypted message.
// Should not happen, but should also not throw an error.
// MDN for unencrypted message is not encrypted.
let mut msg = Message::new(Viewtype::Text);
let chat_alice = alice.create_email_chat(&bob).await.id;
let sent = alice.send_msg(chat_alice, &mut msg).await;
let rcvd = bob.recv_msg(&sent).await;
message::markseen_msgs(&bob, vec![rcvd.id]).await?;
let queued_mdn = mdn(&bob, rcvd.from_id, &rcvd.rfc724_mid, vec![]).await?;
// Only sending MDN to self (if BCC-self is enabled) because address-contact recipient has no key.
assert!(queued_mdn.recipients.is_empty());
// MDNs are always encrypted, even if requested for unencrypted message.
assert!(queued_mdn.encryption.is_encrypted());
bob.assert_warn("has no key, sending to self").await;
Ok(())
}
/// Tests that MDNs sent in reply to encrypted messages are encrypted
/// and MDNs for unencrypted messages are not created.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mdn_create_encrypted() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
alice
.set_config(Config::Displayname, Some("Alice Exampleorg"))
.await?;
let bob = tcm.bob().await;
bob.set_config(Config::Displayname, Some("Bob Examplenet"))
.await?;
bob.set_config(Config::Selfstatus, Some("Bob Examplenet"))
.await?;
bob.set_config_bool(Config::MdnsEnabled, true).await?;
let mimefactory =
MimeFactory::from_mdn(&bob, rcvd.from_id, rcvd.rfc724_mid.clone(), vec![]).await?;
assert!(!mimefactory.will_be_encrypted());
let bob_addr = bob.get_primary_self_addr().await?;
let rendered_msg = mimefactory.render(&bob, &bob_addr).await?;
assert!(!rendered_msg.message.contains("Bob Examplenet"));
assert!(!rendered_msg.message.contains("Alice Exampleorg"));
let bob_alice_contact = bob.add_or_lookup_contact(&alice).await;
assert_eq!(bob_alice_contact.get_authname(), "Alice Exampleorg");
@@ -345,9 +319,10 @@ async fn test_mdn_create_encrypted() -> Result<()> {
let rcvd = tcm.send_recv(&alice, &bob, "Heyho").await;
message::markseen_msgs(&bob, vec![rcvd.id]).await?;
let queued_mdn = mdn(&bob, rcvd.from_id, &rcvd.rfc724_mid, vec![]).await?;
assert!(queued_mdn.encryption.is_encrypted());
let rendered_msg = render_queued_mail_with_context(queued_mdn, &bob).await?;
let mimefactory = MimeFactory::from_mdn(&bob, rcvd.from_id, rcvd.rfc724_mid, vec![]).await?;
assert!(mimefactory.will_be_encrypted());
let bob_addr = bob.get_primary_self_addr().await?;
let rendered_msg = mimefactory.render(&bob, &bob_addr).await?;
assert!(!rendered_msg.message.contains("Bob Examplenet"));
assert!(!rendered_msg.message.contains("Alice Exampleorg"));
@@ -366,7 +341,6 @@ async fn test_mdn_sent_to_all_relays() -> Result<()> {
// Bob's key gets a second relay address and Alice merges the newer key.
let bob_secret_key = load_self_secret_key(bob).await?;
let bob_public_key = secret_key_to_public_key(
bob,
bob_secret_key,
u32::try_from(time())? + 100,
"bob@example.net",
@@ -374,8 +348,8 @@ async fn test_mdn_sent_to_all_relays() -> Result<()> {
)?;
import_public_key(alice, &bob_public_key).await?;
let queued_mdn = mdn(alice, rcvd.from_id, &rcvd.rfc724_mid, vec![]).await?;
let mut recipients = queued_mdn.recipients;
let mimefactory = MimeFactory::from_mdn(alice, rcvd.from_id, rcvd.rfc724_mid, vec![]).await?;
let mut recipients = mimefactory.recipients();
recipients.sort();
assert_eq!(recipients, vec!["bob@example.net", "bob@relay2.example"]);
@@ -389,8 +363,9 @@ async fn test_mdn_autocrypt_throttle() -> Result<()> {
alice: &TestContext,
rcvd: &Message,
) -> Result<bool> {
let queued_mdn = mdn(bob, rcvd.from_id, &rcvd.rfc724_mid, vec![]).await?;
let rendered_msg = render_queued_mail_with_context(queued_mdn, bob).await?;
let mf = MimeFactory::from_mdn(bob, rcvd.from_id, rcvd.rfc724_mid.clone(), vec![]).await?;
let addr = bob.get_primary_self_addr().await?;
let rendered_msg = mf.render(bob, &addr).await?;
let mime = MimeMessage::from_bytes(alice, rendered_msg.message.as_bytes()).await?;
Ok(mime.autocrypt_fingerprint.is_some())
}
@@ -671,7 +646,8 @@ async fn test_render_reply() {
let recipients = mimefactory.recipients();
assert_eq!(recipients, vec!["charlie@example.net"]);
let rendered_msg = mimefactory.render(t).await.unwrap();
let addr = t.get_primary_self_addr().await.unwrap();
let rendered_msg = mimefactory.render(t, &addr).await.unwrap();
let mail = mailparse::parse_mail(rendered_msg.message.as_bytes()).unwrap();
assert_eq!(
@@ -818,7 +794,7 @@ async fn test_protected_headers_directive() -> Result<()> {
// Long messages are truncated and MimeMessage::decoded_data is set for them. We need
// decoded_data to check presence of the necessary headers.
msg.set_text("a".repeat(constants::DC_DESIRED_TEXT_LEN + 1));
msg.set_file_from_bytes(&bob, "foo.bar", b"content", None)?;
msg.set_file_from_bytes(&bob, "foo.bar", "content".as_bytes(), None)?;
let sent = bob.send_msg(chat, &mut msg).await;
assert!(msg.get_showpadlock());
assert!(sent.payload.contains("\r\nSubject: [...]\r\n"));
@@ -1123,6 +1099,7 @@ To: <bob@example.net>
Subject: Message from alice@example.org
References: <MESSAGE_ID@localhost>
Chat-Version: 1.0
Chat-Disposition-Notification-To: alice@example.org
Content-Transfer-Encoding: 7bit
Hello!"#
@@ -1149,7 +1126,7 @@ async fn test_render_unencrypted_msg_with_attachment() -> Result<()> {
.await;
let mut msg = Message::new(Viewtype::File);
msg.set_text("Hello!".to_string());
msg.set_file_from_bytes(alice, "foo.bar", b"content", None)?;
msg.set_file_from_bytes(alice, "foo.bar", "content".as_bytes(), None)?;
let sent = alice.send_msg(chat.id, &mut msg).await;
let unencrypted = normalized_payload(sent).await;
@@ -1174,6 +1151,7 @@ To: <bob@example.net>
Subject: Message from alice@example.org
References: <MESSAGE_ID@localhost>
Chat-Version: 1.0
Chat-Disposition-Notification-To: alice@example.org
--BOUNDARY
+10 -7
View File
@@ -294,7 +294,11 @@ impl MimeMessage {
&mut wants_mdn,
&mail,
);
headers_removed.extend(headers.extract_if(|k, _v| is_hidden(k)).map(|(k, _v)| k));
headers_removed.extend(
headers
.extract_if(|k, _v| is_hidden(k))
.map(|(k, _v)| k.to_string()),
);
// Parse hidden headers.
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
@@ -931,10 +935,9 @@ impl MimeMessage {
// See if an MDN is requested from the other side
if self.decryption_error.is_none()
&& (!self.parts.is_empty() || matches!(&self.pre_message, PreMessageMode::Pre { .. }))
&& self.wants_mdn
&& self.was_encrypted() // Do not send MDNs for unencrypted messages.
&& self.incoming
&& let Some(part) = self.parts.last_mut()
&& self.wants_mdn
&& self.incoming
&& let Some(part) = self.parts.last_mut()
{
part.param.set_int(Param::WantsMdn, 1);
}
@@ -948,7 +951,7 @@ impl MimeMessage {
typ: Viewtype::Text,
..Default::default()
};
if self.wants_mdn && self.was_encrypted() && self.incoming {
if self.wants_mdn && self.incoming {
part.param.set_int(Param::WantsMdn, 1);
}
if let Some(ref subject) = self.get_subject()
@@ -1748,7 +1751,7 @@ impl MimeMessage {
headers_removed.extend(
headers
.extract_if(|k, _v| has_header_protection || is_protected(k))
.map(|(k, _v)| k),
.map(|(k, _v)| k.to_string()),
);
if has_header_protection {
+2 -4
View File
@@ -410,8 +410,7 @@ impl ProxyConfig {
};
let tcp_stream =
crate::net::connect_tcp(context, &hostname, http_config.port, load_cache)
.await
.context("Failed to connect to HTTP proxy")?;
.await?;
let auth = if let Some((username, password)) = &http_config.user_password {
Some((username.as_str(), password.as_str()))
} else {
@@ -430,8 +429,7 @@ impl ProxyConfig {
let tcp_stream =
crate::net::connect_tcp(context, &hostname, https_config.port, load_cache)
.await
.context("Failed to connect to HTTPS proxy")?;
.await?;
let use_sni = true;
let tls_stream = wrap_rustls(
&hostname,
+1 -1
View File
@@ -46,7 +46,7 @@ use crate::mimeparser::SystemMessage;
/// The length of an ed25519 `PublicKey`, in bytes.
const PUBLIC_KEY_LENGTH: usize = 32;
const PUBLIC_KEY_STUB: &[u8] = b"static_string";
const PUBLIC_KEY_STUB: &[u8] = "static_string".as_bytes();
/// Store Iroh peer channels for the context.
#[derive(Debug)]
+10 -10
View File
@@ -86,14 +86,14 @@ async fn test_can_communicate() {
.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(alice, alice_webxdc.id, b"alice -> bob".to_vec())
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"alice -> bob" {
if data == "alice -> bob".as_bytes() {
break;
} else {
panic!(
@@ -107,14 +107,14 @@ async fn test_can_communicate() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice".to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"bob -> alice" {
if data == "bob -> alice".as_bytes() {
break;
} else {
panic!(
@@ -149,14 +149,14 @@ async fn test_can_communicate() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice 2".to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice 2".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"bob -> alice 2" {
if data == "bob -> alice 2".as_bytes() {
break;
} else {
panic!(
@@ -314,14 +314,14 @@ async fn test_can_reconnect() {
.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(alice, alice_webxdc.id, b"alice -> bob".to_vec())
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"alice -> bob" {
if data == "alice -> bob".as_bytes() {
break;
} else {
panic!(
@@ -373,14 +373,14 @@ async fn test_can_reconnect() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice".to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"bob -> alice" {
if data == "bob -> alice".as_bytes() {
break;
} else {
panic!(
+269 -11
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 sha2::Sha256;
use crate::configure::MAX_RELAYS;
use crate::key::{DcKey, Fingerprint};
pub(crate) mod autocrypt2;
/// Preferred symmetric encryption algorithm.
const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128;
@@ -83,13 +87,62 @@ 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-03.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| {
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.
@@ -151,10 +204,11 @@ pub fn pk_encrypt(
) -> Result<String> {
tokio::task::block_in_place(|| {
let mut rng = thread_rng();
let now = pgp::types::Timestamp::now();
let pkeys = public_keys_for_encryption
.iter()
.filter_map(select_pk_for_encryption);
.filter_map(|key| select_pk_for_encryption(now.as_secs(), key));
let msg = MessageBuilder::from_bytes("", plain);
let encoded_msg = match seipd_version {
@@ -310,6 +364,161 @@ pub fn symm_encrypt_message(
})
}
/// Minimizes the signatures of a subkey.
///
/// Keeps at most one subkey binding signature
/// and at most one revocation signature,
/// preferring the newest signatures.
///
/// Subkey binding signature is kept
/// even if the revocation signature exists
/// because according to
/// <https://www.rfc-editor.org/rfc/rfc9580.html#name-openpgp-version-6-certifica>
/// "Every subkey MUST have at least one Subkey Binding signature."
/// Distributing subkey with only a revocation signature
/// is not allowed according to the standard,
/// so we keep a subkey binding signature next to it
/// for interoperability.
///
/// This function does not check if the signatures are valid.
/// Such properties should be validated when importing OpenPGP certificates.
fn minimize_subpacket_signatures(signatures: Vec<Signature>) -> Vec<Signature> {
let mut newest_revocation_signature: Option<Signature> = None;
let mut newest_binding_signature: Option<Signature> = None;
for signature in signatures {
let Some(config) = signature.config() else {
// Skip unknown signatures.
continue;
};
match config.typ {
SignatureType::SubkeyBinding => {
if newest_binding_signature
.as_ref()
.is_none_or(|s| s.created() < signature.created())
{
newest_binding_signature = Some(signature)
}
}
SignatureType::SubkeyRevocation => {
if newest_revocation_signature
.as_ref()
.is_none_or(|s| s.created() < signature.created())
{
newest_revocation_signature = Some(signature)
}
}
_ => continue,
}
}
newest_revocation_signature
.into_iter()
.chain(newest_binding_signature)
.collect()
}
/// Minimizes OpenPGP certificate for Autocrypt and Autocrypt-Gossip headers.
pub fn minimize_autocrypt_certificate(certificate: &SignedPublicKey) -> SignedPublicKey {
let primary_key = certificate.primary_key.clone();
let details = certificate.details.clone();
// Select the newest non-expiring subkey and the newest expiring subkey.
let fallback_subkey = certificate
.public_subkeys
.iter()
.filter(|subkey| {
subkey
.signatures
.iter()
.find(|signature| {
signature
.config()
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
})
.is_some_and(|signature| signature.key_expiration_time().is_none())
})
.max_by_key(|subkey| {
subkey
.signatures
.iter()
.find(|signature| {
signature
.config()
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
})
.map(|signature| signature.created().unwrap_or(subkey.created_at()))
});
let rotating_subkey = certificate
.public_subkeys
.iter()
.filter(|subkey| {
subkey
.signatures
.iter()
.find(|signature| {
signature
.config()
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
})
.is_some_and(|signature| signature.key_expiration_time().is_some())
})
.max_by_key(|subkey| {
subkey
.signatures
.iter()
.find(|signature| {
signature
.config()
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
})
.map(|signature| signature.created().unwrap_or(subkey.created_at()))
});
let public_subkeys: Vec<_> = fallback_subkey
.into_iter()
.chain(rotating_subkey)
.cloned()
.collect();
// We do not want to ever gossip more than two subkeys
// to save the traffic.
debug_assert!(public_subkeys.len() <= 2);
SignedPublicKey {
primary_key,
details,
public_subkeys,
}
}
/// Merges two OpenPGP subkeys.
fn merge_openpgp_subkey(old_subkey: &mut SignedPublicSubKey, new_subkey: SignedPublicSubKey) {
debug_assert_eq!(old_subkey.fingerprint(), new_subkey.fingerprint());
old_subkey.signatures = minimize_subpacket_signatures(
std::mem::take(&mut old_subkey.signatures)
.into_iter()
.chain(new_subkey.signatures)
.collect(),
);
}
/// Merges OpenPGP subkey vectors.
pub fn merge_openpgp_subkeys(
subkeys: impl IntoIterator<Item = SignedPublicSubKey>,
) -> Result<Vec<SignedPublicSubKey>> {
let mut merged_subkeys: BTreeMap<_, SignedPublicSubKey> = BTreeMap::new();
for subkey in subkeys {
let imprint = subkey.imprint::<Sha256>()?;
match merged_subkeys.entry(imprint) {
BTreeMapEntry::Vacant(entry) => {
entry.insert(subkey);
}
BTreeMapEntry::Occupied(entry) => {
merge_openpgp_subkey(entry.into_mut(), subkey);
}
}
}
Ok(merged_subkeys.into_values().collect())
}
/// Merges and minimizes OpenPGP certificates.
///
/// Keeps at most one direct key signature and
@@ -346,7 +555,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,
@@ -401,7 +610,7 @@ pub fn merge_openpgp_certificates(
// such as Alice's key in `test-data/key/alice-secret.asc`.
let best_user: Option<SignedUser> = old_users
.into_iter()
.chain(new_users)
.chain(new_users.clone())
.filter_map(|SignedUser { id, signatures }| {
// Select the best signature for each User ID.
// If User ID has no valid signatures, it is filtered out.
@@ -422,7 +631,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,
@@ -485,7 +742,8 @@ pub(crate) fn relay_addrs(public_key: &SignedPublicKey, addr: &str) -> Vec<Strin
/// Returns true if the key can be encrypted to, i.e. has an encryption subkey.
pub(crate) fn pubkey_can_encrypt(public_key: &SignedPublicKey) -> bool {
select_pk_for_encryption(public_key).is_some()
let now = pgp::types::Timestamp::now();
select_pk_for_encryption(now.as_secs(), public_key).is_some()
}
/// Returns true if public key advertises SEIPDv2 feature.
+588
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());
}
}
+1 -1
View File
@@ -384,7 +384,7 @@ fn test_merge_openpgp_certificates() {
// Cannot merge certificates with different primary key.
assert!(merge_openpgp_certificates(alice.clone(), bob.clone()).is_err());
assert!(merge_openpgp_certificates(bob, alice).is_err());
assert!(merge_openpgp_certificates(bob.clone(), alice.clone()).is_err());
}
/// Test PQC support.
+9 -5
View File
@@ -1,6 +1,6 @@
//! Handle plain text together with some attributes.
use regex::regex;
use std::sync::LazyLock;
use crate::simplify::remove_message_footer;
@@ -25,8 +25,12 @@ impl PlainText {
/// Convert plain text to HTML.
/// The function handles quotes, links, fixed and floating text paragraphs.
pub fn to_html(&self) -> String {
let linkify_mail_re = regex!(r"\b([\w.\-+]+@[\w.\-]+)\b");
let linkify_url_re = regex!(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)");
static LINKIFY_MAIL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\b([\w.\-+]+@[\w.\-]+)\b").unwrap());
static LINKIFY_URL_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)").unwrap()
});
let lines: Vec<&str> = self.text.lines().collect();
let (lines, _footer) = remove_message_footer(&lines);
@@ -48,12 +52,12 @@ impl PlainText {
// to avoid double encoding, we escape our html-entities by \r that must not be used in the string elsewhere.
let line = line.to_string().replace('\r', "");
let mut line = linkify_mail_re
let mut line = LINKIFY_MAIL_RE
.replace_all(&line, "\rLTa href=\rQUOTmailto:$1\rQUOT\rGT$1\rLT/a\rGT")
.as_ref()
.to_string();
line = linkify_url_re
line = LINKIFY_URL_RE
.replace_all(&line, "\rLTa href=\rQUOT$1\rQUOT\rGT$1\rLT/a\rGT")
.as_ref()
.to_string();
+10 -8
View File
@@ -2,13 +2,13 @@
mod dclogin_scheme;
use std::collections::BTreeMap;
use std::sync::LazyLock;
use anyhow::{Context as _, Result, anyhow, bail, ensure};
pub use dclogin_scheme::LoginOptions;
pub(crate) use dclogin_scheme::login_param_from_login_qr;
use deltachat_contact_tools::{ContactAddress, addr_normalize, may_be_valid_addr};
use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, percent_encode};
use regex::regex;
use serde::Deserialize;
use crate::autorelay::login_param_from_host;
@@ -764,7 +764,7 @@ fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
let addr = server_config.addr();
let host = addr.host();
let host = addr.host().to_string();
let port = addr.port();
Ok(Qr::Proxy {
url: qr.to_string(),
@@ -1071,11 +1071,16 @@ async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
Qr::from_address(context, name, &addr, None).await
}
static VCARD_NAME_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
/// Extract address for the vcard scheme.
///
/// Scheme: `VCARD:BEGIN\nN:last name;first name;...;\nEMAIL;<type>:addr...;`
async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
let name = regex!(r"(?m)^N:([^;]*);([^;\n]*)")
let name = VCARD_NAME_RE
.captures(qr)
.and_then(|caps| {
let last_name = caps.get(1)?.as_str().trim();
@@ -1085,10 +1090,7 @@ async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
})
.unwrap_or_default();
let addr = if let Some(cap) = regex!(r"(?m)^EMAIL([^:\n]*):([^;\n]*)")
.captures(qr)
.and_then(|caps| caps.get(2))
{
let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
normalize_address(cap.as_str().trim())?
} else {
bail!("Bad e-mail address");
@@ -1122,7 +1124,7 @@ fn normalize_address(addr: &str) -> Result<String> {
ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
Ok(new_addr)
Ok(new_addr.to_string())
}
#[cfg(test)]
+1 -1
View File
@@ -120,7 +120,7 @@ pub(super) fn decode_login(qr: &str) -> Result<Qr> {
};
Ok(Qr::Login {
address: addr,
address: addr.to_owned(),
options,
})
} else {
+1 -1
View File
@@ -165,7 +165,7 @@ async fn set_msg_id_reaction(
.await?;
if chat
.param
.update_timestamp(Param::LastReactionTimestamp, timestamp)
.update_timestamp(Param::LastReactionTimestamp, timestamp)?
{
chat.param
.set_i64(Param::LastReactionMsgId, i64::from(msg_id.to_u32()));
+6 -7
View File
@@ -483,7 +483,7 @@ pub(crate) async fn receive_imf_inner(
}
let trash = || async {
let msg_ids = vec![insert_tombstone(context, rfc724_mid, false).await?];
let msg_ids = vec![insert_tombstone(context, rfc724_mid).await?];
Ok(Some(ReceivedMsg {
chat_id: ChatId::TRASH,
state: MessageState::Undefined,
@@ -668,15 +668,14 @@ pub(crate) async fn receive_imf_inner(
match res {
securejoin::HandshakeMessage::Done | securejoin::HandshakeMessage::Ignore => {
let needs_delete_job = res == securejoin::HandshakeMessage::Done;
let msg_id = insert_tombstone(context, rfc724_mid, needs_delete_job).await?;
let msg_id = insert_tombstone(context, rfc724_mid).await?;
received_msg = Some(ReceivedMsg {
chat_id: ChatId::TRASH,
state: MessageState::InSeen,
hidden: false,
sort_timestamp: mime_parser.timestamp_sent,
msg_ids: vec![msg_id],
needs_delete_job,
needs_delete_job: res == securejoin::HandshakeMessage::Done,
});
}
securejoin::HandshakeMessage::Propagate => {
@@ -2312,7 +2311,7 @@ INSERT INTO msgs
// This way, `LastSubject` actually refers to the most recent message _shown_ in the chat.
if chat
.param
.update_timestamp(Param::SubjectTimestamp, sort_timestamp)
.update_timestamp(Param::SubjectTimestamp, sort_timestamp)?
{
// write the last subject even if empty -
// otherwise a reply may get an outdated subject.
@@ -2402,7 +2401,7 @@ async fn handle_edit_delete(
let Some(msg_id) = message::rfc724_mid_exists(context, rfc724_mid).await? else {
warn!(context, "Delete message: {rfc724_mid:?} not found.");
// Insert a tombstone so that the message will be ignored if it arrives later within a period specified in prune_tombstones().
insert_tombstone(context, rfc724_mid, false).await?;
insert_tombstone(context, rfc724_mid).await?;
continue;
};
@@ -3415,7 +3414,7 @@ async fn apply_chat_name_avatar_and_description_changes(
&& is_from_in_chat
&& chat
.param
.update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)
.update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)?
{
info!(context, "Group-avatar change for {}.", chat.id);
match avatar_action {
+1 -3
View File
@@ -416,9 +416,7 @@ async fn test_escaped_from() {
);
let msg = get_chat_msg(&t, chat_id, 0, 1).await;
assert_eq!(msg.text, "hello");
// MDN request from unencrypted message is ignored.
assert!(msg.param.get_int(Param::WantsMdn).is_none());
assert_eq!(msg.param.get_int(Param::WantsMdn).unwrap(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+21 -16
View File
@@ -33,14 +33,14 @@ pub(crate) use qrinvite::QrInvite;
use crate::token::Namespace;
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_').remove(b'@');
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_');
fn inviter_progress(
context: &Context,
contact_id: ContactId,
chat_id: ChatId,
chat_type: Chattype,
) {
) -> Result<()> {
// No other values are used.
let progress = 1000;
context.emit_event(EventType::SecurejoinInviterProgress {
@@ -49,6 +49,8 @@ fn inviter_progress(
chat_type,
progress,
});
Ok(())
}
/// Shorten name to max. `length` characters.
@@ -119,18 +121,21 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
let fingerprint = self_fingerprint(context).await?;
let self_addrs = context.get_self_addrs().await?;
let mut encoded_addrs = self_addrs
.iter()
.map(|addr| utf8_percent_encode(addr, DISALLOWED_CHARACTERS).to_string());
let self_addr_urlencoded = encoded_addrs.next().context("No self addr configured")?;
let encoded_extra_relays: Vec<String> = encoded_addrs.collect();
let self_addr = context.get_primary_self_addr().await?;
let self_addr_urlencoded = utf8_percent_encode(&self_addr, DISALLOWED_CHARACTERS).to_string();
let r_param = if encoded_extra_relays.is_empty() {
"".to_string()
} else {
format!("&r={}", encoded_extra_relays.join(","))
};
let r_param = context
.get_self_addrs()
.await?
.into_iter()
.filter(|addr| *addr != self_addr)
.reduce(|acc, addr| {
format!(
"{acc},{}",
utf8_percent_encode(&addr, DISALLOWED_CHARACTERS)
)
})
.map_or(String::default(), |addrs| format!("&r={addrs}"));
let self_name = context
.get_config(Config::Displayname)
@@ -654,7 +659,7 @@ pub(crate) async fn handle_securejoin_handshake(
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
}
inviter_progress(context, contact_id, joining_chat_id, chat.typ);
inviter_progress(context, contact_id, joining_chat_id, chat.typ)?;
// IMAP-delete the message to avoid handling it by another device and adding the
// member twice. Another device will know the member's key from Autocrypt-Gossip.
Ok(HandshakeMessage::Done)
@@ -665,7 +670,7 @@ pub(crate) async fn handle_securejoin_handshake(
.await
.context("failed sending vc-contact-confirm message")?;
inviter_progress(context, contact_id, chat_id, Chattype::Single);
inviter_progress(context, contact_id, chat_id, Chattype::Single)?;
Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed)
}
}
@@ -812,7 +817,7 @@ pub(crate) async fn observe_securejoin_on_other_device(
// and tests which don't care about the chat ID,
// so we pass invalid chat ID here.
let chat_id = ChatId::new(0);
inviter_progress(context, contact_id, chat_id, chat_type);
inviter_progress(context, contact_id, chat_id, chat_type)?;
}
if matches!(step, SecureJoinStep::MemberAdded) {
-65
View File
@@ -6,7 +6,6 @@ use crate::chat::{CantSendReason, ChatId, add_contact_to_chat, remove_contact_fr
use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::key::self_fingerprint;
use crate::message::rfc724_mid_exists_ext;
use crate::qr::Qr;
use crate::receive_imf::receive_imf;
use crate::stock_str::{self, messages_e2ee_info_msg};
@@ -14,7 +13,6 @@ use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, get_chat_msg, sync,
};
use crate::transport::add_pseudo_transport;
#[derive(PartialEq)]
enum SetupContactCase {
@@ -1144,43 +1142,6 @@ async fn test_get_securejoin_qr_name_is_last() -> Result<()> {
Ok(())
}
/// Test that addresses in QR codes are percent-encoded.
/// `@` should not be encoded unnecessarily,
/// since this would just make the QR code longer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_securejoin_qr_encoding() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// `@` in email addresses must not be percent-encoded:
add_pseudo_transport(alice, "asdf@example.org").await?;
// But `%` does need percent-encoding:
add_pseudo_transport(alice, "jk%l@example.net").await?;
let qr = get_securejoin_qr(alice, None).await?;
assert!(
qr.contains("a=jk%25l@example.net"),
"{qr} doesn't contain 'a=jk%25l@example.net'"
);
assert!(
qr.contains("r=asdf@example.org,alice@example.org"),
"{qr} doesn't contain 'r=asdf@example.org,alice@example.org'"
);
let qr = check_qr(bob, &qr).await?;
let Qr::AskVerifyContact { mut addrs, .. } = qr else {
unreachable!()
};
addrs.sort();
assert_eq!(
addrs,
vec!["alice@example.org", "asdf@example.org", "jk%l@example.net",]
);
Ok(())
}
/// QR codes should not get arbitrary big because of long names.
/// The truncation, however, should not let the url end with a `.`, which is a call for trouble in linkfiers.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1564,29 +1525,3 @@ async fn test_deduplicate_member_added() -> Result<()> {
Ok(())
}
/// Tests that a handled join request is also marked as "deleted"
/// in the database, so that if a copy of the request arrives via
/// other relays in the future, then this copy will also be deleted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_join_request_deleted_on_all_relays() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = chat::create_group(alice, "Group").await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
bob.add_or_lookup_contact_id(alice).await;
join_securejoin(bob, &qr).await?;
let request = bob.pop_sent_msg().await;
alice.recv_msg_trash(&request).await;
let rfc724_mid = Message::load_from_db(bob, request.sender_msg_id)
.await?
.rfc724_mid;
let (_, deleted) = rfc724_mid_exists_ext(alice, &rfc724_mid, "deleted=1")
.await?
.unwrap();
assert!(deleted);
Ok(())
}
+25 -33
View File
@@ -22,6 +22,7 @@ use crate::log::{LogExt, warn};
use crate::message::Message;
use crate::message::{self, MsgId};
use crate::mimefactory;
use crate::mimefactory::MimeFactory;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionBufStream;
use crate::scheduler::connectivity::ConnectivityStore;
@@ -355,7 +356,7 @@ pub(crate) async fn insert_into_smtp(
queued_msg: &QueuedMail,
) -> Result<()> {
let now = tools::time();
let msg_id = message::insert_tombstone(context, rfc724_mid, false).await?;
let msg_id = message::insert_tombstone(context, rfc724_mid).await?;
context
.sql
.transaction(|transaction| queue::enqueue_mail(transaction, now, msg_id, queued_msg, None))
@@ -651,24 +652,6 @@ pub(crate) async fn send_smtp_messages(context: &Context, connection: &mut Smtp)
Ok(())
}
async fn delete_mdns_by_rfc724_mid(
context: &Context,
rfc724_mid: &str,
additional_rfc724_mids: Vec<String>,
) -> Result<()> {
context
.sql
.transaction(|transaction| {
let mut stmt = transaction.prepare("DELETE FROM smtp_mdns WHERE rfc724_mid = ?")?;
stmt.execute((rfc724_mid,))?;
for additional_rfc724_mid in additional_rfc724_mids {
stmt.execute((additional_rfc724_mid,))?;
}
Ok(())
})
.await
}
/// Tries to send MDN for message identified by `rfc724_mdn` to `contact_id`.
///
/// Attempts to aggregate additional MDNs for `contact_id` into sent MDN.
@@ -704,30 +687,28 @@ async fn send_mdn_rfc724_mid(
)
.await?;
let queued_mdn = mimefactory::mdn(
let mimefactory = MimeFactory::from_mdn(
context,
contact_id,
rfc724_mid,
rfc724_mid.to_string(),
additional_rfc724_mids.clone(),
)
.await?;
let bcc_self = queued_mdn.bcc_self;
let encrypted = queued_mdn.encryption.is_encrypted();
let mut recipients = queued_mdn.recipients.clone();
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let encrypted = mimefactory.will_be_encrypted();
let mut recipients = if contact_id == ContactId::SELF {
Vec::new()
} else {
mimefactory.recipients()
};
let from = smtp
.from
.as_ref()
.context("No From address, not connected")?
.to_string();
let rendered_msg =
mimefactory::render_queued_mail(queued_mdn, &public_key, &secret_key, from.clone())?;
let rendered_msg = Box::pin(mimefactory.render(context, &from)).await?;
let body = rendered_msg.message;
if bcc_self {
if context.get_config_bool(Config::BccSelf).await? {
add_self_recipients(context, &mut recipients, encrypted, from).await?;
}
let recipients: Vec<_> = recipients
@@ -739,13 +720,24 @@ async fn send_mdn_rfc724_mid(
.ok()
})
.collect();
message::insert_tombstone(context, &rendered_msg.rfc724_mid, false).await?;
message::insert_tombstone(context, &rendered_msg.rfc724_mid).await?;
match smtp_send(context, &recipients, &body, smtp, None).await {
SendResult::Success => {
if !recipients.is_empty() {
info!(context, "Successfully sent MDN for {rfc724_mid}.");
}
delete_mdns_by_rfc724_mid(context, rfc724_mid, additional_rfc724_mids).await?;
context
.sql
.transaction(|transaction| {
let mut stmt =
transaction.prepare("DELETE FROM smtp_mdns WHERE rfc724_mid = ?")?;
stmt.execute((rfc724_mid,))?;
for additional_rfc724_mid in additional_rfc724_mids {
stmt.execute((additional_rfc724_mid,))?;
}
Ok(())
})
.await?;
Ok(true)
}
SendResult::Retry => {
+1 -1
View File
@@ -109,7 +109,7 @@ pub struct SideEffects {
}
/// Email message ready to be queued with the side effects that should be applied at the same time.
pub(crate) type ToBeQueuedMail = (QueuedMail, SideEffects);
pub(crate) type ToBeQueuedMail = (QueuedMail, Option<SideEffects>);
/// Process side effects and store queued mail.
pub(crate) fn enqueue_mail(
+1 -7
View File
@@ -14,7 +14,7 @@ use crate::config::Config;
use crate::context::Context;
use crate::debug_logging::set_debug_logging_xdc;
use crate::ephemeral::start_ephemeral_timers;
use crate::imex::{self, BLOBS_BACKUP_NAME};
use crate::imex::BLOBS_BACKUP_NAME;
use crate::location;
use crate::log::{LogExt, warn};
use crate::message::MsgId;
@@ -827,12 +827,6 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
);
}
imex::maybe_readd_backup_transfer_msg(context)
.await
.context("Failed to re-add backup transfer message")
.log_err(context)
.ok();
if let Err(err) = incremental_vacuum(context).await {
warn!(context, "Failed to run incremental vacuum: {err:#}.");
}
+12 -4
View File
@@ -32,6 +32,8 @@ fn migrate_key_contacts(
context: &Context,
transaction: &mut rusqlite::Transaction<'_>,
) -> std::result::Result<(), anyhow::Error> {
info!(context, "Starting key-contact transition.");
// =============================== Step 1: ===============================
// Alter tables
transaction.execute_batch(
@@ -77,12 +79,13 @@ fn migrate_key_contacts(
.optional()
.context("Step 0")?
else {
// Not yet configured, no need to migrate key-contacts.
info!(
context,
"Not yet configured, no need to migrate key-contacts"
);
return Ok(());
};
info!(context, "Starting key-contact transition.");
// =============================== Step 2: ===============================
// Create up to 3 new contacts for every contact that has a peerstate:
// one from the Autocrypt key fingerprint, one from the verified key fingerprint,
@@ -1933,9 +1936,14 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint);
inc_and_check(&mut migration_version, 132)?;
if dbversion < migration_version {
let start = Time::now();
sql.execute_migration_transaction(|t| migrate_key_contacts(context, t), migration_version)
.await?;
info!(
context,
"key-contacts migration took {:?} in total.",
time_elapsed(&start),
);
// Schedule `msgs_to_key_contacts()`.
context
.set_config_internal(Config::LastHousekeeping, None)
+4 -3
View File
@@ -35,7 +35,7 @@ use crate::context::Context;
use crate::events::{Event, EventEmitter, EventType, Events};
use crate::key::{self, DcKey, self_fingerprint};
use crate::message::{Message, MessageState, MsgId};
use crate::mimefactory;
use crate::mimefactory::{self, MimeFactory};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::pgp::SeipdVersion;
use crate::receive_imf::{ReceivedMsg, receive_imf};
@@ -852,8 +852,9 @@ ORDER BY id"
/// Receives a read receipt from `reader`, who received `msg`.
pub async fn recv_mdn(&self, reader: &TestContext, msg: &Message) -> Result<()> {
let queued_mdn = mimefactory::mdn(reader, msg.from_id, &msg.rfc724_mid, vec![]).await?;
let mdn = mimefactory::render_queued_mail_with_context(queued_mdn, reader)
let mdn = MimeFactory::from_mdn(reader, msg.from_id, msg.rfc724_mid.clone(), vec![])
.await?
.render(reader, &reader.get_primary_self_addr().await?)
.await?
.message;
receive_imf(self, mdn.as_bytes(), false).await?;
-1
View File
@@ -220,7 +220,6 @@ async fn maybe_warn_on_bad_time(context: &Context, now: i64, known_past_timestam
),
Some(&mut msg),
true,
time(),
)
.await
.ok();
+13 -13
View File
@@ -27,7 +27,7 @@ impl Context {
Ok(param.parse().unwrap_or_default())
},
)?;
let update = param.update_timestamp(scope, new_timestamp);
let update = param.update_timestamp(scope, new_timestamp)?;
if update {
transaction.execute(
"UPDATE contacts SET param=? WHERE id=?",
@@ -57,7 +57,7 @@ impl ChatId {
let param: String = row.get(0)?;
Ok(param.parse().unwrap_or_default())
})?;
let update = param.update_timestamp(scope, new_timestamp);
let update = param.update_timestamp(scope, new_timestamp)?;
if update {
transaction.execute(
"UPDATE chats SET param=? WHERE id=?",
@@ -73,13 +73,13 @@ impl ChatId {
impl Params {
/// Updates a param's timestamp in memory, if reasonable.
/// Returns true if the caller shall update the settings belonging to the scope.
pub(crate) fn update_timestamp(&mut self, scope: Param, new_timestamp: i64) -> bool {
pub(crate) fn update_timestamp(&mut self, scope: Param, new_timestamp: i64) -> Result<bool> {
let old_timestamp = self.get_i64(scope).unwrap_or_default();
if new_timestamp >= old_timestamp {
self.set_i64(scope, new_timestamp);
return true;
return Ok(true);
}
false
Ok(false)
}
}
@@ -96,18 +96,18 @@ mod tests {
let mut params = Params::new();
let ts = time();
assert!(params.update_timestamp(Param::LastSubject, ts));
assert!(params.update_timestamp(Param::LastSubject, ts)); // same timestamp -> update
assert!(params.update_timestamp(Param::LastSubject, ts + 10));
assert!(!params.update_timestamp(Param::LastSubject, ts)); // `ts` is now too old
assert!(!params.update_timestamp(Param::LastSubject, 0));
assert!(params.update_timestamp(Param::LastSubject, ts)?);
assert!(params.update_timestamp(Param::LastSubject, ts)?); // same timestamp -> update
assert!(params.update_timestamp(Param::LastSubject, ts + 10)?);
assert!(!params.update_timestamp(Param::LastSubject, ts)?); // `ts` is now too old
assert!(!params.update_timestamp(Param::LastSubject, 0)?);
assert_eq!(params.get_i64(Param::LastSubject).unwrap(), ts + 10);
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)); // stay unset -> update ...
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)); // ... also on multiple calls
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)?); // stay unset -> update ...
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)?); // ... also on multiple calls
assert_eq!(params.get_i64(Param::GroupNameTimestamp).unwrap(), 0);
assert!(!params.update_timestamp(Param::AvatarTimestamp, -1));
assert!(!params.update_timestamp(Param::AvatarTimestamp, -1)?);
assert_eq!(params.get_i64(Param::AvatarTimestamp), None);
Ok(())
+3 -3
View File
@@ -350,7 +350,7 @@ impl Context {
if let Some(ref document) = status_update_item.document
&& instance
.param
.update_timestamp(Param::WebxdcDocumentTimestamp, timestamp)
.update_timestamp(Param::WebxdcDocumentTimestamp, timestamp)?
{
instance.param.set(Param::WebxdcDocument, document);
param_changed = true;
@@ -359,10 +359,10 @@ impl Context {
if let Some(ref summary) = status_update_item.summary
&& instance
.param
.update_timestamp(Param::WebxdcSummaryTimestamp, timestamp)
.update_timestamp(Param::WebxdcSummaryTimestamp, timestamp)?
{
let summary = sanitize_bidi_characters(summary);
instance.param.set(Param::WebxdcSummary, summary);
instance.param.set(Param::WebxdcSummary, summary.clone());
param_changed = true;
}
+14 -12
View File
@@ -1201,28 +1201,30 @@ async fn test_get_webxdc_blob_with_subdirs() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest() -> Result<()> {
let result = parse_webxdc_manifest(br#"key = syntax error"#);
let result = parse_webxdc_manifest(r#"key = syntax error"#.as_bytes());
assert!(result.is_err());
let manifest = parse_webxdc_manifest(br#"no_name = "no name, no icon""#)?;
let manifest = parse_webxdc_manifest(r#"no_name = "no name, no icon""#.as_bytes())?;
assert_eq!(manifest.name, None);
let manifest = parse_webxdc_manifest(br#"name = "name, no icon""#)?;
let manifest = parse_webxdc_manifest(r#"name = "name, no icon""#.as_bytes())?;
assert_eq!(manifest.name, Some("name, no icon".to_string()));
let manifest = parse_webxdc_manifest(
br#"name = "foo"
icon = "bar""#,
r#"name = "foo"
icon = "bar""#
.as_bytes(),
)?;
assert_eq!(manifest.name, Some("foo".to_string()));
let manifest = parse_webxdc_manifest(
br#"name = "foz"
r#"name = "foz"
icon = "baz"
add_item = "that should be just ignored"
[section]
sth_for_the = "future""#,
sth_for_the = "future""#
.as_bytes(),
)?;
assert_eq!(manifest.name, Some("foz".to_string()));
Ok(())
@@ -1230,13 +1232,13 @@ sth_for_the = "future""#,
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest_min_api() -> Result<()> {
let manifest = parse_webxdc_manifest(br#"min_api = 3"#)?;
let manifest = parse_webxdc_manifest(r#"min_api = 3"#.as_bytes())?;
assert_eq!(manifest.min_api, Some(3));
let result = parse_webxdc_manifest(br#"min_api = "1""#);
let result = parse_webxdc_manifest(r#"min_api = "1""#.as_bytes());
assert!(result.is_err());
let result = parse_webxdc_manifest(br#"min_api = 1.2"#);
let result = parse_webxdc_manifest(r#"min_api = 1.2"#.as_bytes());
assert!(result.is_err());
Ok(())
@@ -1244,10 +1246,10 @@ async fn test_parse_webxdc_manifest_min_api() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest_source_code_url() -> Result<()> {
let result = parse_webxdc_manifest(br#"source_code_url = 3"#);
let result = parse_webxdc_manifest(r#"source_code_url = 3"#.as_bytes());
assert!(result.is_err());
let manifest = parse_webxdc_manifest(br#"source_code_url = "https://foo.bar""#)?;
let manifest = parse_webxdc_manifest(r#"source_code_url = "https://foo.bar""#.as_bytes())?;
assert_eq!(
manifest.source_code_url,
Some("https://foo.bar".to_string())
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB