mirror of
https://github.com/chatmail/core.git
synced 2026-04-01 21:12:13 +03:00
Compare commits
75 Commits
d6dacdcd27
...
1b42e74b52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b42e74b52 | ||
|
|
d7b3a85127 | ||
|
|
822a99ea9c | ||
|
|
bf02785a36 | ||
|
|
01b2aa0f66 | ||
|
|
fb46c34b55 | ||
|
|
9393753190 | ||
|
|
d9056fd187 | ||
|
|
7b17b1f8b8 | ||
|
|
d8d7f12af0 | ||
|
|
0150d38ddd | ||
|
|
11b6a108f5 | ||
|
|
54858361a9 | ||
|
|
6a705a3ef6 | ||
|
|
a23e41ea6d | ||
|
|
bdca3e5c09 | ||
|
|
a61a25f139 | ||
|
|
5404e683eb | ||
|
|
80acc9d467 | ||
|
|
3c5af7a559 | ||
|
|
f7e9973fb4 | ||
|
|
c0a3d77301 | ||
|
|
9891c2a531 | ||
|
|
f85c625799 | ||
|
|
b30f93a57d | ||
|
|
a95bf77868 | ||
|
|
d26fa715b5 | ||
|
|
1b43aac356 | ||
|
|
53acfaa054 | ||
|
|
874e38c146 | ||
|
|
cce8e3bc5a | ||
|
|
1e20055523 | ||
|
|
abb93cd79d | ||
|
|
5f84be718a | ||
|
|
d1c3a679a0 | ||
|
|
0c4e32363e | ||
|
|
89b5675b83 | ||
|
|
8ff8ba7416 | ||
|
|
e3a7d555a8 | ||
|
|
964bbad53e | ||
|
|
a1eb376131 | ||
|
|
3c4ce17f1e | ||
|
|
0622289420 | ||
|
|
c928015f20 | ||
|
|
b10acd194e | ||
|
|
b94792706a | ||
|
|
bfae2296b7 | ||
|
|
e7625ca231 | ||
|
|
ab08a47298 | ||
|
|
b85fa84a37 | ||
|
|
ccd3caf4a7 | ||
|
|
5f248954dc | ||
|
|
a6c7958739 | ||
|
|
c724e2981c | ||
|
|
ffd9f80f8b | ||
|
|
42cb9fe890 | ||
|
|
914486cb32 | ||
|
|
526b3b0271 | ||
|
|
1c439b5ef4 | ||
|
|
f97c75f146 | ||
|
|
76a36a35bf | ||
|
|
dc4249a2ff | ||
|
|
957c0b7c56 | ||
|
|
8df9b9e4d9 | ||
|
|
692e1019b0 | ||
|
|
2511b03726 | ||
|
|
c39651a8d4 | ||
|
|
8230336936 | ||
|
|
e1e8407905 | ||
|
|
ffce0dfc9a | ||
|
|
e2eec2f1f8 | ||
|
|
072c0061ee | ||
|
|
cb783ffc12 | ||
|
|
af182a85a3 | ||
|
|
7d8989a068 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -20,7 +20,7 @@ permissions: {}
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
RUST_VERSION: 1.93.0
|
||||
RUST_VERSION: 1.94.0
|
||||
|
||||
# Minimum Supported Rust Version
|
||||
MSRV: 1.88.0
|
||||
|
||||
14
.github/workflows/deltachat-rpc-server.yml
vendored
14
.github/workflows/deltachat-rpc-server.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- 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@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- 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@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- 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@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- 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@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- 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@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- 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@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
|
||||
- name: Download Linux aarch64 binary
|
||||
uses: actions/download-artifact@v7
|
||||
|
||||
6
.github/workflows/nix.yml
vendored
6
.github/workflows/nix.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- run: nix fmt flake.nix -- --check
|
||||
|
||||
build:
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- run: nix build .#${{ matrix.installable }}
|
||||
|
||||
build-macos:
|
||||
@@ -105,5 +105,5 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- run: nix build .#${{ matrix.installable }}
|
||||
|
||||
2
.github/workflows/repl.yml
vendored
2
.github/workflows/repl.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- name: Build
|
||||
run: nix build .#deltachat-repl-win64
|
||||
- name: Upload binary
|
||||
|
||||
4
.github/workflows/upload-docs.yml
vendored
4
.github/workflows/upload-docs.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # Fetch history to calculate VCS version number.
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- name: Build Python documentation
|
||||
run: nix build .#python-docs
|
||||
- name: Upload to py.delta.chat
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # Fetch history to calculate VCS version number.
|
||||
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
|
||||
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
|
||||
- name: Build C documentation
|
||||
run: nix build .#docs
|
||||
- name: Upload to c.delta.chat
|
||||
|
||||
19
.github/workflows/zizmor-scan.yml
vendored
19
.github/workflows/zizmor-scan.yml
vendored
@@ -6,26 +6,21 @@ on:
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: zizmor latest via PyPI
|
||||
name: Run zizmor
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b
|
||||
|
||||
- name: Run zizmor
|
||||
run: uvx zizmor --format sarif . > results.sarif
|
||||
|
||||
- name: Upload SARIF file
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
category: zizmor
|
||||
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
|
||||
|
||||
6
.github/zizmor.yml
vendored
Normal file
6
.github/zizmor.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
rules:
|
||||
unpinned-uses:
|
||||
config:
|
||||
policies:
|
||||
actions/*: ref-pin
|
||||
dependabot/*: ref-pin
|
||||
140
CHANGELOG.md
140
CHANGELOG.md
@@ -1,5 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
## [2.45.0] - 2026-03-14
|
||||
|
||||
### API-Changes
|
||||
|
||||
- JSON-RPC: add `createQrSvg` ([#7949](https://github.com/chatmail/core/pull/7949)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Do not read own public key from the database.
|
||||
- Securejoin v3, encrypt all securejoin messages ([#7754](https://github.com/chatmail/core/pull/7754)).
|
||||
- Domain separation between securejoin auth tokens and broadcast channel secrets ([#7981](https://github.com/chatmail/core/pull/7981)).
|
||||
- Merge OpenPGP certificates and distribute relays in them.
|
||||
- Advertise SEIPDv2 feature for new keys.
|
||||
- Don't depend on cleartext `Chat-Version`, `In-Reply-To`, and `References` headers for `prefetch_should_download` ([#7932](https://github.com/chatmail/core/pull/7932)).
|
||||
- Don't send unencrypted `In-Reply-To` and `References` headers ([#7935](https://github.com/chatmail/core/pull/7935)).
|
||||
- Don't send unencrypted `Auto-Submitted` header ([#7938](https://github.com/chatmail/core/pull/7938)).
|
||||
- Remove QR code tokens sync compatibility code.
|
||||
- Mutex to prevent fetching from multiple IMAP servers at the same time.
|
||||
- Add support to gif stickers ([#7941](https://github.com/chatmail/core/pull/7941))
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix the deadlock by adding a mutex around `wal_checkpoint()`.
|
||||
- Do not run more than one housekeeping at a time.
|
||||
- ffi: don't steal Arc in `dc_jsonrpc_init` ([#7962](https://github.com/chatmail/core/pull/7962)).
|
||||
- Handle the case that the user starts a securejoin, and then deletes the contact ([#7883](https://github.com/chatmail/core/pull/7883)).
|
||||
- Do not trash pre-message if it is received twice.
|
||||
- Set `is_chatmail` during initial configuration.
|
||||
- vCard: Improve property value escaping ([#7931](https://github.com/chatmail/core/pull/7931)).
|
||||
- Percent-decode the address in `dclogin://` URLs.
|
||||
- Make broadcast owner and subscriber hidden contacts for each other ([#7856](https://github.com/chatmail/core/pull/7856)).
|
||||
- Set proper placeholder texts for system messages ([#7953](https://github.com/chatmail/core/pull/7953)).
|
||||
- Add "member added" messages to `OutBroadcast` when executing `SetPgpContacts` sync message ([#7952](https://github.com/chatmail/core/pull/7952)).
|
||||
- Correct channel system messages ([#7959](https://github.com/chatmail/core/pull/7959)).
|
||||
- Drop messages encrypted with the wrong symmetric secret ([#7963](https://github.com/chatmail/core/pull/7963)).
|
||||
- Fix debug assert message incorrectly talking about past members in the current member branch.
|
||||
- Update device chats at the end of configuration.
|
||||
- `deltachat_rpc_client`: make `@futuremethod` decorator keep method metadata.
|
||||
- Use the correct chat description stock string again ([#7939](https://github.com/chatmail/core/pull/7939)).
|
||||
- Use correct string for encryption info.
|
||||
|
||||
### CI
|
||||
|
||||
- Update Rust to 1.94.0.
|
||||
- Allow non-hash references for `actions/*` and `dependabot/*`.
|
||||
- update zizmor workflow to use zizmorcore/zizmor-action.
|
||||
|
||||
### Documentation
|
||||
|
||||
- update `store_self_keypair()` documentation.
|
||||
- Fix documentation for membership change stock strings ([#7944](https://github.com/chatmail/core/pull/7944)).
|
||||
- use correct define for 'description changed' info message.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Un-resultify `KeyPair::new()`.
|
||||
- Remove `KeyPair` type.
|
||||
- pgp: do not use legacy key ID except for IssuerKeyId subpacket.
|
||||
- `use super::*` in qr::dclogin_scheme.
|
||||
- Move WAL checkpointing into `sql::pool` submodule.
|
||||
- Order self addresses by addition timestamp.
|
||||
|
||||
### Tests
|
||||
|
||||
- Remove arbitrary timeouts from `test_4_lowlevel.py`.
|
||||
- Fix flaky `test_qr_securejoin_broadcast` ([#7937](https://github.com/chatmail/core/pull/7937)).
|
||||
- Work around `test_sync_broadcast_and_send_message` flakiness.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- bump version to 2.44.0-dev.
|
||||
- cargo: bump futures from 0.3.31 to 0.3.32.
|
||||
- cargo: bump quick-xml from 0.39.0 to 0.39.2.
|
||||
- cargo: bump criterion from 0.8.1 to 0.8.2.
|
||||
- cargo: bump tempfile from 3.24.0 to 3.25.0.
|
||||
- cargo: bump async-imap from 0.11.1 to 0.11.2.
|
||||
- cargo: bump regex from 1.12.2 to 1.12.3.
|
||||
- cargo: bump hyper-util from 0.1.19 to 0.1.20.
|
||||
- cargo: bump anyhow from 1.0.100 to 1.0.102.
|
||||
- cargo: bump syn from 2.0.114 to 2.0.117.
|
||||
- cargo: bump proptest from 1.9.0 to 1.10.0.
|
||||
- cargo: bump strum from 0.27.2 to 0.28.0.
|
||||
- cargo: bump strum_macros from 0.27.2 to 0.28.0.
|
||||
- cargo: bump quinn-proto from 0.11.9 to 0.11.14.
|
||||
|
||||
## [2.44.0] - 2026-02-27
|
||||
|
||||
### Build system
|
||||
|
||||
- git-cliff: do not capitalize the first letter of commit message.
|
||||
|
||||
### Documentation
|
||||
|
||||
- RELEASE.md: add section about dealing with antivirus false positives.
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- improve logging of connection failures.
|
||||
- add backup versions to the importing error message.
|
||||
- add context to message loading failures.
|
||||
- Add 📱 to all webxdc summaries ([#7790](https://github.com/chatmail/core/pull/7790)).
|
||||
- Send webxdc name instead of raw file name in pre-messages. Display it in summary ([#7790](https://github.com/chatmail/core/pull/7790)).
|
||||
- rpc: add startup health-check and propagate server errors.
|
||||
|
||||
### Fixes
|
||||
|
||||
- imex: do not call `set_config` before running SQL migrations ([#7851](https://github.com/chatmail/core/pull/7851)).
|
||||
- add missing group description strings to cffi.
|
||||
- chat-description-changed text in old clients ([#7870](https://github.com/chatmail/core/pull/7870)).
|
||||
- add cffi type for "Description changed" info message.
|
||||
- If there was no chat description, and it's set to be an empty string, don't send out a "chat description changed" message ([#7879](https://github.com/chatmail/core/pull/7879)).
|
||||
- Make clicking on broadcast member-added messages work always ([#7882](https://github.com/chatmail/core/pull/7882)).
|
||||
- tolerate empty existing directory in Accounts::new() ([#7886](https://github.com/chatmail/core/pull/7886)).
|
||||
- If importing a backup fails, delete the partially-imported profile ([#7885](https://github.com/chatmail/core/pull/7885)).
|
||||
- Don't generate new timestamp for re-sent messages ([#7889](https://github.com/chatmail/core/pull/7889)).
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- cargo: update async-native-tls from 0.5.0 to 0.6.0.
|
||||
- add dev-version bump instructions to RELEASE.md (bumping to 2.44.0-dev).
|
||||
- deps: bump cachix/install-nix-action from 31.9.0 to 31.9.1.
|
||||
|
||||
### Performance
|
||||
|
||||
- batched event reception.
|
||||
|
||||
### Refactor
|
||||
|
||||
- enable clippy::arithmetic_side_effects lint.
|
||||
- imex: check for overflow when adding blob size.
|
||||
- http: saturating addition to calculate cache expiration timestamp.
|
||||
- Move migrations to the end of the file ([#7895](https://github.com/chatmail/core/pull/7895)).
|
||||
- do not chain Autocrypt key verification to parsing.
|
||||
|
||||
### Tests
|
||||
|
||||
- fail fast when CHATMAIL_DOMAIN is unset.
|
||||
|
||||
## [2.43.0] - 2026-02-17
|
||||
|
||||
### Features / Changes
|
||||
@@ -7767,3 +7905,5 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
|
||||
[2.41.0]: https://github.com/chatmail/core/compare/v2.40.0..v2.41.0
|
||||
[2.42.0]: https://github.com/chatmail/core/compare/v2.41.0..v2.42.0
|
||||
[2.43.0]: https://github.com/chatmail/core/compare/v2.42.0..v2.43.0
|
||||
[2.44.0]: https://github.com/chatmail/core/compare/v2.43.0..v2.44.0
|
||||
[2.45.0]: https://github.com/chatmail/core/compare/v2.44.0..v2.45.0
|
||||
|
||||
280
Cargo.lock
generated
280
Cargo.lock
generated
@@ -124,12 +124,9 @@ checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
]
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
@@ -180,7 +177,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -192,7 +189,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -274,9 +271,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-imap"
|
||||
version = "0.11.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8da885da5980f3934831e6370445c0e0e44ef251d7792308b39e908915a41d09"
|
||||
checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66"
|
||||
dependencies = [
|
||||
"async-channel 2.5.0",
|
||||
"async-compression",
|
||||
@@ -342,7 +339,7 @@ checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -461,7 +458,7 @@ checksum = "57413e4b276d883b77fb368b7b33ae6a5eb97692852d49a5394d4f72ba961827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
@@ -473,9 +470,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.1"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "bitvec"
|
||||
@@ -601,7 +598,7 @@ dependencies = [
|
||||
"proc-macro-crate 2.0.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1039,9 +1036,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d883447757bb0ee46f233e9dc22eb84d93a9508c9b868687b274fc431d886bf"
|
||||
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||
dependencies = [
|
||||
"alloca",
|
||||
"anes",
|
||||
@@ -1065,9 +1062,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4"
|
||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
@@ -1119,7 +1116,7 @@ version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"crossterm_winapi",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
@@ -1231,7 +1228,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1272,7 +1269,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1283,7 +1280,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1303,7 +1300,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -1371,8 +1368,8 @@ dependencies = [
|
||||
"sha2",
|
||||
"shadowsocks",
|
||||
"smallvec",
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
"strum 0.28.0",
|
||||
"strum_macros 0.28.0",
|
||||
"tagger",
|
||||
"tempfile",
|
||||
"testdir",
|
||||
@@ -1413,7 +1410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel 2.5.0",
|
||||
@@ -1434,7 +1431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-repl"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1450,7 +1447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1474,12 +1471,12 @@ name = "deltachat_derive"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1528,7 +1525,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1558,7 +1555,7 @@ dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1568,7 +1565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1597,7 +1594,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
@@ -1609,7 +1606,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
@@ -1675,7 +1672,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1738,7 +1735,7 @@ checksum = "7a4102713839a8c01c77c165bc38ef2e83948f6397fa1e1dcfacec0f07b149d3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1867,7 +1864,7 @@ dependencies = [
|
||||
"heck 0.4.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1887,7 +1884,7 @@ checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1997,7 +1994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.1.3",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
@@ -2104,9 +2101,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -2132,9 +2129,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -2157,15 +2154,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
@@ -2174,9 +2171,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
@@ -2193,32 +2190,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -2228,7 +2225,6 @@ dependencies = [
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
@@ -2640,13 +2636,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.19"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.1.0",
|
||||
"http-body",
|
||||
@@ -2797,7 +2792,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3087,7 +3082,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3259,9 +3254,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
version = "0.2.182"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
@@ -3275,7 +3270,7 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"redox_syscall 0.5.12",
|
||||
]
|
||||
@@ -3309,9 +3304,9 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
@@ -3368,6 +3363,12 @@ version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lru_time_cache"
|
||||
version = "0.11.11"
|
||||
@@ -3589,7 +3590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0800eae8638a299eaa67476e1c6b6692922273e0f7939fd188fc861c837b9cd2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3684,7 +3685,7 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -3789,7 +3790,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3850,7 +3851,7 @@ dependencies = [
|
||||
"proc-macro-crate 3.2.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3859,7 +3860,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3930,7 +3931,7 @@ version = "0.10.72"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -3947,7 +3948,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4134,7 +4135,7 @@ dependencies = [
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4243,7 +4244,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4362,7 +4363,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4392,7 +4393,7 @@ version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -4594,7 +4595,7 @@ dependencies = [
|
||||
"proc-macro-error-attr2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4608,11 +4609,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40"
|
||||
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
"rand_chacha 0.9.0",
|
||||
@@ -4660,9 +4661,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.0"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2e3bf4aa9d243beeb01a7b3bc30b77cfe2c44e24ec02d751a7104a53c2c49a1"
|
||||
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -4687,13 +4688,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.9"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.2.16",
|
||||
"rand 0.8.5",
|
||||
"getrandom 0.3.3",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -4895,7 +4897,7 @@ version = "0.5.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4911,9 +4913,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.2"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -5082,7 +5084,7 @@ version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
@@ -5126,7 +5128,7 @@ version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.14",
|
||||
@@ -5135,14 +5137,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.1",
|
||||
]
|
||||
|
||||
@@ -5203,7 +5205,7 @@ version = "16.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62fd9ca5ebc709e8535e8ef7c658eb51457987e48c98ead2be482172accc408d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"clipboard-win",
|
||||
"fd-lock",
|
||||
@@ -5282,7 +5284,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5330,7 +5332,7 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
@@ -5414,7 +5416,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5425,7 +5427,7 @@ checksum = "330f01ce65a3a5fe59a60c82f3c9a024b573b8a6e875bd233fe5f934e71d54e3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5649,7 +5651,7 @@ version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5691,7 +5693,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5774,9 +5776,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
@@ -5788,19 +5790,19 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5871,9 +5873,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5897,7 +5899,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5934,7 +5936,7 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
@@ -5969,14 +5971,14 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.24.0"
|
||||
version = "3.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.3",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.1",
|
||||
]
|
||||
|
||||
@@ -6032,7 +6034,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6043,7 +6045,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6158,7 +6160,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6358,7 +6360,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6408,7 +6410,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6453,7 +6455,7 @@ dependencies = [
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6654,7 +6656,7 @@ dependencies = [
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -6689,7 +6691,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
@@ -6872,7 +6874,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6883,7 +6885,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6894,7 +6896,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6905,7 +6907,7 @@ checksum = "cb26fd936d991781ea39e87c3a27285081e3c0da5ca0fcbc02d368cc6f52ff01"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7217,7 +7219,7 @@ version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7364,7 +7366,7 @@ dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7387,7 +7389,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -7415,7 +7417,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7435,7 +7437,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -7456,7 +7458,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7478,7 +7480,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.88"
|
||||
@@ -96,8 +96,8 @@ sha-1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
shadowsocks = { version = "1.23.1", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
|
||||
smallvec = "1.15.1"
|
||||
strum = "0.27"
|
||||
strum_macros = "0.27"
|
||||
strum = "0.28"
|
||||
strum_macros = "0.28"
|
||||
tagger = "4.3.4"
|
||||
textwrap = "0.16.2"
|
||||
thiserror = { workspace = true }
|
||||
@@ -186,7 +186,7 @@ chrono = { version = "0.4.43", default-features = false }
|
||||
deltachat-contact-tools = { path = "deltachat-contact-tools" }
|
||||
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
|
||||
deltachat = { path = ".", default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures = "0.3.32"
|
||||
futures-lite = "2.6.1"
|
||||
libc = "0.2"
|
||||
log = "0.4"
|
||||
@@ -194,12 +194,12 @@ mailparse = "0.16.1"
|
||||
nu-ansi-term = "0.50"
|
||||
num-traits = "0.2"
|
||||
rand = "0.9"
|
||||
regex = "1.10"
|
||||
regex = "1.12"
|
||||
rusqlite = "0.37"
|
||||
sanitize-filename = "0.6"
|
||||
serde = "1.0"
|
||||
serde_json = "1"
|
||||
tempfile = "3.24.0"
|
||||
tempfile = "3.25.0"
|
||||
thiserror = "2"
|
||||
tokio = "1"
|
||||
tokio-util = "0.7.18"
|
||||
|
||||
@@ -8,43 +8,47 @@
|
||||
//! cargo bench --bench decrypting --features="internals"
|
||||
//! ```
|
||||
//!
|
||||
//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark:
|
||||
//! or, if you want to only run e.g. the 'Decrypt and parse a symmetrically encrypted message' benchmark:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt a symmetrically encrypted message'
|
||||
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt and parse a symmetrically encrypted message'
|
||||
//! ```
|
||||
//!
|
||||
//! You can also pass a substring.
|
||||
//! So, you can run all 'Decrypt and parse' benchmarks with:
|
||||
//! You can also pass a substring:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt and parse'
|
||||
//! cargo bench --bench decrypting --features="internals" -- 'symmetrically'
|
||||
//! ```
|
||||
//!
|
||||
//! Symmetric decryption has to try out all known secrets,
|
||||
//! You can benchmark this by adapting the `NUM_SECRETS` variable.
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use deltachat::internals_for_benches::create_broadcast_secret;
|
||||
use deltachat::internals_for_benches::create_dummy_keypair;
|
||||
use deltachat::internals_for_benches::save_broadcast_secret;
|
||||
use deltachat::securejoin::get_securejoin_qr;
|
||||
use deltachat::{
|
||||
Events,
|
||||
chat::ChatId,
|
||||
config::Config,
|
||||
context::Context,
|
||||
internals_for_benches::key_from_asc,
|
||||
internals_for_benches::parse_and_get_text,
|
||||
internals_for_benches::store_self_keypair,
|
||||
pgp::{KeyPair, SeipdVersion, decrypt, pk_encrypt, symm_encrypt_message},
|
||||
Events, chat::ChatId, config::Config, context::Context, internals_for_benches::key_from_asc,
|
||||
internals_for_benches::parse_and_get_text, internals_for_benches::store_self_keypair,
|
||||
stock_str::StockStrings,
|
||||
};
|
||||
use rand::{Rng, rng};
|
||||
use tempfile::tempdir;
|
||||
|
||||
const NUM_SECRETS: usize = 500;
|
||||
static NUM_BROADCAST_SECRETS: LazyLock<usize> = LazyLock::new(|| {
|
||||
std::env::var("NUM_BROADCAST_SECRETS")
|
||||
.unwrap_or("500".to_string())
|
||||
.parse()
|
||||
.unwrap()
|
||||
});
|
||||
static NUM_AUTH_TOKENS: LazyLock<usize> = LazyLock::new(|| {
|
||||
std::env::var("NUM_AUTH_TOKENS")
|
||||
.unwrap_or("5000".to_string())
|
||||
.parse()
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
async fn create_context() -> Context {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -58,9 +62,7 @@ async fn create_context() -> Context {
|
||||
.await
|
||||
.unwrap();
|
||||
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
|
||||
let public = secret.to_public_key();
|
||||
let key_pair = KeyPair { public, secret };
|
||||
store_self_keypair(&context, &key_pair)
|
||||
store_self_keypair(&context, &secret)
|
||||
.await
|
||||
.expect("Failed to save key");
|
||||
|
||||
@@ -70,66 +72,6 @@ async fn create_context() -> Context {
|
||||
fn criterion_benchmark(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("Decrypt");
|
||||
|
||||
// ===========================================================================================
|
||||
// Benchmarks for decryption only, without any other parsing
|
||||
// ===========================================================================================
|
||||
|
||||
group.sample_size(10);
|
||||
|
||||
group.bench_function("Decrypt a symmetrically encrypted message", |b| {
|
||||
let plain = generate_plaintext();
|
||||
let secrets = generate_secrets();
|
||||
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let secret = secrets[NUM_SECRETS / 2].clone();
|
||||
symm_encrypt_message(
|
||||
plain.clone(),
|
||||
create_dummy_keypair("alice@example.org").unwrap().secret,
|
||||
black_box(&secret),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
b.iter(|| {
|
||||
let mut msg =
|
||||
decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap();
|
||||
let decrypted = msg.as_data_vec().unwrap();
|
||||
|
||||
assert_eq!(black_box(decrypted), plain);
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("Decrypt a public-key encrypted message", |b| {
|
||||
let plain = generate_plaintext();
|
||||
let key_pair = create_dummy_keypair("alice@example.org").unwrap();
|
||||
let secrets = generate_secrets();
|
||||
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
pk_encrypt(
|
||||
plain.clone(),
|
||||
vec![black_box(key_pair.public.clone())],
|
||||
key_pair.secret.clone(),
|
||||
true,
|
||||
true,
|
||||
SeipdVersion::V2,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
b.iter(|| {
|
||||
let mut msg = decrypt(
|
||||
encrypted.clone().into_bytes(),
|
||||
std::slice::from_ref(&key_pair.secret),
|
||||
black_box(&secrets),
|
||||
)
|
||||
.unwrap();
|
||||
let decrypted = msg.as_data_vec().unwrap();
|
||||
|
||||
assert_eq!(black_box(decrypted), plain);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================================
|
||||
// Benchmarks for the whole parsing pipeline, incl. decryption (but excl. receive_imf())
|
||||
// ===========================================================================================
|
||||
@@ -139,7 +81,7 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
|
||||
// "secret" is the shared secret that was used to encrypt text_symmetrically_encrypted.eml.
|
||||
// Put it into the middle of our secrets:
|
||||
secrets[NUM_SECRETS / 2] = "secret".to_string();
|
||||
secrets[*NUM_BROADCAST_SECRETS / 2] = "secret".to_string();
|
||||
|
||||
let context = rt.block_on(async {
|
||||
let context = create_context().await;
|
||||
@@ -148,6 +90,10 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for _i in 0..*NUM_AUTH_TOKENS {
|
||||
get_securejoin_qr(&context, None).await.unwrap();
|
||||
}
|
||||
println!("NUM_AUTH_TOKENS={}", *NUM_AUTH_TOKENS);
|
||||
context
|
||||
});
|
||||
|
||||
@@ -161,7 +107,7 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(text, "Symmetrically encrypted message");
|
||||
assert_eq!(black_box(text), "Symmetrically encrypted message");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -176,7 +122,7 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(text, "hi");
|
||||
assert_eq!(black_box(text), "hi");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -185,17 +131,12 @@ fn criterion_benchmark(c: &mut Criterion) {
|
||||
}
|
||||
|
||||
fn generate_secrets() -> Vec<String> {
|
||||
let secrets: Vec<String> = (0..NUM_SECRETS)
|
||||
let secrets: Vec<String> = (0..*NUM_BROADCAST_SECRETS)
|
||||
.map(|_| create_broadcast_secret())
|
||||
.collect();
|
||||
println!("NUM_BROADCAST_SECRETS={}", *NUM_BROADCAST_SECRETS);
|
||||
secrets
|
||||
}
|
||||
|
||||
fn generate_plaintext() -> Vec<u8> {
|
||||
let mut plain: Vec<u8> = vec![0; 500];
|
||||
rng().fill(&mut plain[..]);
|
||||
plain
|
||||
}
|
||||
|
||||
criterion_group!(benches, criterion_benchmark);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -36,6 +36,45 @@ impl VcardContact {
|
||||
}
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
// https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4
|
||||
s
|
||||
// backslash must be first!
|
||||
.replace(r"\", r"\\")
|
||||
.replace(',', r"\,")
|
||||
.replace(';', r"\;")
|
||||
.replace('\n', r"\n")
|
||||
}
|
||||
|
||||
fn unescape(s: &str) -> String {
|
||||
// https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4
|
||||
let mut out = String::new();
|
||||
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\' {
|
||||
if let Some(next) = chars.next() {
|
||||
match next {
|
||||
'\\' | ',' | ';' => out.push(next),
|
||||
'n' | 'N' => out.push('\n'),
|
||||
_ => {
|
||||
// Invalid escape sequence (keep unchanged)
|
||||
out.push('\\');
|
||||
out.push(next);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Invalid escape sequence (keep unchanged)
|
||||
out.push('\\');
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Returns a vCard containing given contacts.
|
||||
///
|
||||
/// Calling [`parse_vcard()`] on the returned result is a reverse operation.
|
||||
@@ -46,10 +85,6 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String {
|
||||
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace(',', "\\,")
|
||||
}
|
||||
|
||||
let mut res = "".to_string();
|
||||
for c in contacts {
|
||||
// Mustn't contain ',', but it's easier to escape than to error out.
|
||||
@@ -124,7 +159,7 @@ pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
|
||||
fn vcard_property<'a>(line: &'a str, property: &str) -> Option<(&'a str, String)> {
|
||||
let (params, value) = vcard_property_raw(line, property)?;
|
||||
// Some fields can't contain commas, but unescape them everywhere for safety.
|
||||
Some((params, value.replace("\\,", ",")))
|
||||
Some((params, unescape(value)))
|
||||
}
|
||||
fn base64_key(line: &str) -> Option<&str> {
|
||||
let (params, value) = vcard_property_raw(line, "key")?;
|
||||
|
||||
@@ -91,7 +91,7 @@ fn test_make_and_parse_vcard() {
|
||||
authname: "Alice Wonderland".to_string(),
|
||||
key: Some("[base64-data]".to_string()),
|
||||
profile_image: Some("image in Base64".to_string()),
|
||||
biography: Some("Hi, I'm Alice".to_string()),
|
||||
biography: Some("Hi,\nI'm Alice; and this is a backslash: \\".to_string()),
|
||||
timestamp: Ok(1713465762),
|
||||
},
|
||||
VcardContact {
|
||||
@@ -110,7 +110,7 @@ fn test_make_and_parse_vcard() {
|
||||
FN:Alice Wonderland\r\n\
|
||||
KEY:data:application/pgp-keys;base64\\,[base64-data]\r\n\
|
||||
PHOTO:data:image/jpeg;base64\\,image in Base64\r\n\
|
||||
NOTE:Hi\\, I'm Alice\r\n\
|
||||
NOTE:Hi\\,\\nI'm Alice\\; and this is a backslash: \\\\\r\n\
|
||||
REV:20240418T184242Z\r\n\
|
||||
END:VCARD\r\n",
|
||||
"BEGIN:VCARD\r\n\
|
||||
@@ -276,3 +276,14 @@ END:VCARD",
|
||||
assert!(contacts[0].timestamp.is_err());
|
||||
assert_eq!(contacts[0].profile_image.as_ref().unwrap(), "/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_value_escape_unescape() {
|
||||
let original = "Text, with; chars and a \\ and a newline\nand a literal newline \\n";
|
||||
let expected_escaped = r"Text\, with\; chars and a \\ and a newline\nand a literal newline \\n";
|
||||
|
||||
let escaped = escape(original);
|
||||
assert_eq!(escaped, expected_escaped);
|
||||
let unescaped = unescape(&escaped);
|
||||
assert_eq!(original, unescaped);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
description = "Deltachat FFI"
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -430,14 +430,6 @@ char* dc_get_blobdir (const dc_context_t* context);
|
||||
* 1=send a copy of outgoing messages to self (default).
|
||||
* Sending messages to self is needed for a proper multi-account setup,
|
||||
* however, on the other hand, may lead to unwanted notifications in non-delta clients.
|
||||
* - `mvbox_move` = 1=detect chat messages,
|
||||
* move them to the `DeltaChat` folder,
|
||||
* and watch the `DeltaChat` folder for updates (default),
|
||||
* 0=do not move chat-messages
|
||||
* - `only_fetch_mvbox` = 1=Do not fetch messages from folders other than the
|
||||
* `DeltaChat` folder. Messages will still be fetched from the
|
||||
* spam folder.
|
||||
* 0=watch all folders normally (default)
|
||||
* - `show_emails` = DC_SHOW_EMAILS_OFF (0)=
|
||||
* show direct replies to chats only,
|
||||
* DC_SHOW_EMAILS_ACCEPTED_CONTACTS (1)=
|
||||
@@ -4612,7 +4604,7 @@ int dc_msg_is_info (const dc_msg_t* msg);
|
||||
* and also offer a way to fix the encryption, eg. by a button offering a QR scan
|
||||
* - DC_INFO_WEBXDC_INFO_MESSAGE (32) - Info-message created by webxdc app sending `update.info`
|
||||
* - DC_INFO_CHAT_E2EE (50) - Info-message for "Chat is end-to-end-encrypted"
|
||||
* - DC_INFO_GROUP_NAME_CHANGED (70) - Info-message "Description changed", UI should open the profile with the description
|
||||
* - DC_INFO_GROUP_DESCRIPTION_CHANGED (70) - Info-message "Description changed", UI should open the profile with the description
|
||||
*
|
||||
* For the messages that refer to a CONTACT,
|
||||
* dc_msg_get_info_contact_id() returns the contact ID.
|
||||
@@ -6755,6 +6747,7 @@ void dc_event_unref(dc_event_t* event);
|
||||
* UI usually only takes action in case call UI was opened before, otherwise the event should be ignored.
|
||||
*
|
||||
* @param data1 (int) msg_id ID of the message referring to the call
|
||||
* @param data2 (int) 1 if the call was accepted from this device (process).
|
||||
*/
|
||||
#define DC_EVENT_INCOMING_CALL_ACCEPTED 2560
|
||||
|
||||
@@ -7496,7 +7489,7 @@ void dc_event_unref(dc_event_t* event);
|
||||
|
||||
/// "Messages are end-to-end encrypted."
|
||||
///
|
||||
/// Used in info messages.
|
||||
/// Used in info-messages, UI may add smth. as "Tap to learn more."
|
||||
#define DC_STR_CHAT_PROTECTION_ENABLED 170
|
||||
|
||||
/// "Others will only see this group after you sent a first message."
|
||||
@@ -7579,6 +7572,19 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// `%1$s` and `%2$s` will both be replaced by the name of the inviter.
|
||||
#define DC_STR_SECURE_JOIN_CHANNEL_STARTED 203
|
||||
|
||||
/// "Channel name changed from %1$s to %2$s."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// `%1$s` will be replaced by the old channel name.
|
||||
/// `%2$s` will be replaced by the new channel name.
|
||||
#define DC_STR_CHANNEL_NAME_CHANGED 204
|
||||
|
||||
/// "Channel image changed."
|
||||
///
|
||||
/// Used in status messages.
|
||||
#define DC_STR_CHANNEL_IMAGE_CHANGED 205
|
||||
|
||||
/// "The attachment contains anonymous usage statistics, which help us improve Delta Chat. Thank you!"
|
||||
///
|
||||
/// Used as the message body for statistics sent out.
|
||||
@@ -7617,6 +7623,11 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// "Chat description changed by %1$s."
|
||||
#define DC_STR_GROUP_DESCRIPTION_CHANGED_BY_OTHER 241
|
||||
|
||||
/// "Messages are end-to-end encrypted."
|
||||
///
|
||||
/// Used when creating text for the "Encryption Info" dialogs.
|
||||
#define DC_STR_MESSAGES_ARE_E2EE 242
|
||||
|
||||
/**
|
||||
* @}
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::collections::BTreeMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::fmt::Write;
|
||||
use std::future::Future;
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::ptr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
@@ -679,7 +680,6 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
|
||||
| EventType::ChatModified(_)
|
||||
| EventType::ChatDeleted { .. }
|
||||
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
|
||||
| EventType::IncomingCallAccepted { .. }
|
||||
| EventType::OutgoingCallAccepted { .. }
|
||||
| EventType::CallEnded { .. }
|
||||
| EventType::EventChannelOverflow { .. }
|
||||
@@ -702,6 +702,9 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
|
||||
} => status_update_serial.to_u32() as libc::c_int,
|
||||
EventType::WebxdcRealtimeData { data, .. } => data.len() as libc::c_int,
|
||||
EventType::IncomingCall { has_video, .. } => *has_video as libc::c_int,
|
||||
EventType::IncomingCallAccepted {
|
||||
from_this_device, ..
|
||||
} => *from_this_device as libc::c_int,
|
||||
|
||||
#[allow(unreachable_patterns)]
|
||||
#[cfg(test)]
|
||||
@@ -5150,10 +5153,10 @@ pub unsafe extern "C" fn dc_jsonrpc_init(
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let account_manager = Arc::from_raw(account_manager);
|
||||
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(
|
||||
account_manager.clone(),
|
||||
));
|
||||
let account_manager = ManuallyDrop::new(Arc::from_raw(account_manager));
|
||||
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(Arc::clone(
|
||||
&account_manager,
|
||||
)));
|
||||
|
||||
let (request_handle, receiver) = RpcClient::new();
|
||||
let handle = RpcSession::new(request_handle, cmd_api);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
description = "DeltaChat JSON-RPC API"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
|
||||
@@ -31,7 +31,7 @@ use deltachat::peer_channels::{
|
||||
};
|
||||
use deltachat::provider::get_provider_info;
|
||||
use deltachat::qr::{self, Qr};
|
||||
use deltachat::qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg};
|
||||
use deltachat::qr_code_generator::{create_qr_svg, generate_backup_qr, get_securejoin_qr_svg};
|
||||
use deltachat::reaction::{get_msg_reactions, send_reaction};
|
||||
use deltachat::securejoin;
|
||||
use deltachat::stock_str::StockMessage;
|
||||
@@ -864,6 +864,8 @@ impl CommandApi {
|
||||
/// if `checkQr()` returns `askVerifyContact` or `askVerifyGroup`
|
||||
/// an out-of-band-verification can be joined using `secure_join()`
|
||||
///
|
||||
/// @deprecated as of 2026-03; use create_qr_svg(get_chat_securejoin_qr_code()) instead.
|
||||
///
|
||||
/// chat_id: If set to a group-chat-id,
|
||||
/// the Verified-Group-Invite protocol is offered in the QR code;
|
||||
/// works for protected groups as well as for normal groups.
|
||||
@@ -1980,6 +1982,8 @@ impl CommandApi {
|
||||
/// even if there is no concurrent call to [`CommandApi::provide_backup`],
|
||||
/// but will fail after 60 seconds to avoid deadlocks.
|
||||
///
|
||||
/// @deprecated as of 2026-03; use `create_qr_svg(get_backup_qr())` instead.
|
||||
///
|
||||
/// Returns the QR code rendered as an SVG image.
|
||||
async fn get_backup_qr_svg(&self, account_id: u32) -> Result<String> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
@@ -1993,6 +1997,11 @@ impl CommandApi {
|
||||
generate_backup_qr(&ctx, &qr).await
|
||||
}
|
||||
|
||||
/// Renders the given text as a QR code SVG image.
|
||||
async fn create_qr_svg(&self, text: String) -> Result<String> {
|
||||
create_qr_svg(&text)
|
||||
}
|
||||
|
||||
/// Gets a backup from a remote provider.
|
||||
///
|
||||
/// This retrieves the backup from a remote device over the network and imports it into
|
||||
@@ -2506,7 +2515,10 @@ impl CommandApi {
|
||||
continue;
|
||||
}
|
||||
let sticker_name = sticker_entry.file_name().into_string().unwrap_or_default();
|
||||
if sticker_name.ends_with(".png") || sticker_name.ends_with(".webp") {
|
||||
if sticker_name.ends_with(".png")
|
||||
|| sticker_name.ends_with(".webp")
|
||||
|| sticker_name.ends_with(".gif")
|
||||
{
|
||||
sticker_paths.push(
|
||||
sticker_entry
|
||||
.path()
|
||||
|
||||
@@ -441,6 +441,8 @@ pub enum EventType {
|
||||
msg_id: u32,
|
||||
/// ID of the chat which the message belongs to.
|
||||
chat_id: u32,
|
||||
/// The call was accepted from this device (process).
|
||||
from_this_device: bool,
|
||||
},
|
||||
|
||||
/// Outgoing call accepted.
|
||||
@@ -634,9 +636,14 @@ impl From<CoreEventType> for EventType {
|
||||
place_call_info,
|
||||
has_video,
|
||||
},
|
||||
CoreEventType::IncomingCallAccepted { msg_id, chat_id } => IncomingCallAccepted {
|
||||
CoreEventType::IncomingCallAccepted {
|
||||
msg_id,
|
||||
chat_id,
|
||||
from_this_device,
|
||||
} => IncomingCallAccepted {
|
||||
msg_id: msg_id.to_u32(),
|
||||
chat_id: chat_id.to_u32(),
|
||||
from_this_device,
|
||||
},
|
||||
CoreEventType::OutgoingCallAccepted {
|
||||
msg_id,
|
||||
|
||||
@@ -23,6 +23,12 @@ pub struct EnteredLoginParam {
|
||||
/// Imap server port.
|
||||
pub imap_port: Option<u16>,
|
||||
|
||||
/// IMAP server folder.
|
||||
///
|
||||
/// Defaults to "INBOX" if not set.
|
||||
/// Should not be an empty string.
|
||||
pub imap_folder: Option<String>,
|
||||
|
||||
/// Imap socket security.
|
||||
pub imap_security: Option<Socket>,
|
||||
|
||||
@@ -66,6 +72,7 @@ impl From<dc::EnteredLoginParam> for EnteredLoginParam {
|
||||
password: param.imap.password,
|
||||
imap_server: param.imap.server.into_option(),
|
||||
imap_port: param.imap.port.into_option(),
|
||||
imap_folder: param.imap.folder.into_option(),
|
||||
imap_security: imap_security.into_option(),
|
||||
imap_user: param.imap.user.into_option(),
|
||||
smtp_server: param.smtp.server.into_option(),
|
||||
@@ -85,14 +92,15 @@ impl TryFrom<EnteredLoginParam> for dc::EnteredLoginParam {
|
||||
fn try_from(param: EnteredLoginParam) -> Result<Self> {
|
||||
Ok(Self {
|
||||
addr: param.addr,
|
||||
imap: dc::EnteredServerLoginParam {
|
||||
imap: dc::EnteredImapLoginParam {
|
||||
server: param.imap_server.unwrap_or_default(),
|
||||
port: param.imap_port.unwrap_or_default(),
|
||||
folder: param.imap_folder.unwrap_or_default(),
|
||||
security: param.imap_security.unwrap_or_default().into(),
|
||||
user: param.imap_user.unwrap_or_default(),
|
||||
password: param.password,
|
||||
},
|
||||
smtp: dc::EnteredServerLoginParam {
|
||||
smtp: dc::EnteredSmtpLoginParam {
|
||||
server: param.smtp_server.unwrap_or_default(),
|
||||
port: param.smtp_port.unwrap_or_default(),
|
||||
security: param.smtp_security.unwrap_or_default().into(),
|
||||
|
||||
@@ -19,6 +19,8 @@ pub enum QrObject {
|
||||
invitenumber: String,
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
/// Ask the user whether to join the group.
|
||||
AskVerifyGroup {
|
||||
@@ -34,6 +36,8 @@ pub enum QrObject {
|
||||
invitenumber: String,
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
/// Ask the user whether to join the broadcast channel.
|
||||
AskJoinBroadcast {
|
||||
@@ -54,6 +58,8 @@ pub enum QrObject {
|
||||
invitenumber: String,
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
/// Contact fingerprint is verified.
|
||||
///
|
||||
@@ -229,6 +235,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
} => {
|
||||
let contact_id = contact_id.to_u32();
|
||||
let fingerprint = fingerprint.to_string();
|
||||
@@ -237,6 +244,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
}
|
||||
}
|
||||
Qr::AskVerifyGroup {
|
||||
@@ -246,6 +254,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
} => {
|
||||
let contact_id = contact_id.to_u32();
|
||||
let fingerprint = fingerprint.to_string();
|
||||
@@ -256,6 +265,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
}
|
||||
}
|
||||
Qr::AskJoinBroadcast {
|
||||
@@ -265,6 +275,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
authcode,
|
||||
invitenumber,
|
||||
is_v3,
|
||||
} => {
|
||||
let contact_id = contact_id.to_u32();
|
||||
let fingerprint = fingerprint.to_string();
|
||||
@@ -275,6 +286,7 @@ impl From<Qr> for QrObject {
|
||||
fingerprint,
|
||||
authcode,
|
||||
invitenumber,
|
||||
is_v3,
|
||||
}
|
||||
}
|
||||
Qr::FprOk { contact_id } => {
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "2.44.0-dev"
|
||||
"version": "2.46.0-dev"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-repl"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
license = "MPL-2.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
RPC client connects to standalone Delta Chat RPC server `deltachat-rpc-server`
|
||||
and provides asynchronous interface to it.
|
||||
`rpc.start()` performs a health-check RPC call to verify the server
|
||||
started successfully and will raise an error if startup fails
|
||||
(e.g. if the accounts directory could not be used).
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat-rpc-client"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
license = "MPL-2.0"
|
||||
description = "Python client for Delta Chat core JSON-RPC interface"
|
||||
classifiers = [
|
||||
|
||||
@@ -8,7 +8,7 @@ from .const import EventType, SpecialContactId
|
||||
from .contact import Contact
|
||||
from .deltachat import DeltaChat
|
||||
from .message import Message
|
||||
from .rpc import Rpc
|
||||
from .rpc import JsonRpcError, Rpc
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
@@ -19,6 +19,7 @@ __all__ = [
|
||||
"Contact",
|
||||
"DeltaChat",
|
||||
"EventType",
|
||||
"JsonRpcError",
|
||||
"Message",
|
||||
"SpecialContactId",
|
||||
"Rpc",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import argparse
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -186,6 +187,7 @@ class futuremethod: # noqa: N801
|
||||
"""Decorator for async methods."""
|
||||
|
||||
def __init__(self, func):
|
||||
functools.update_wrapper(self, func)
|
||||
self._func = func
|
||||
|
||||
def __get__(self, instance, owner=None):
|
||||
|
||||
@@ -54,7 +54,12 @@ class RpcMethod:
|
||||
class Rpc:
|
||||
"""RPC client."""
|
||||
|
||||
def __init__(self, accounts_dir: Optional[str] = None, rpc_server_path="deltachat-rpc-server", **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
accounts_dir: Optional[str] = None,
|
||||
rpc_server_path="deltachat-rpc-server",
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize RPC client.
|
||||
|
||||
The 'kwargs' arguments will be passed to subprocess.Popen().
|
||||
@@ -79,8 +84,15 @@ class Rpc:
|
||||
self.events_thread: Thread
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start RPC server subprocess."""
|
||||
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE}
|
||||
"""Start RPC server subprocess and wait for successful initialization.
|
||||
|
||||
This method blocks until the RPC server responds to an initial
|
||||
health-check RPC call (get_system_info).
|
||||
If the server fails to start
|
||||
(e.g., due to an invalid accounts directory),
|
||||
a JsonRpcError is raised.
|
||||
"""
|
||||
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
|
||||
if sys.version_info >= (3, 11):
|
||||
# Prevent subprocess from capturing SIGINT.
|
||||
popen_kwargs["process_group"] = 0
|
||||
@@ -90,6 +102,7 @@ class Rpc:
|
||||
|
||||
popen_kwargs.update(self._kwargs)
|
||||
self.process = subprocess.Popen(self.rpc_server_path, **popen_kwargs)
|
||||
|
||||
self.id_iterator = itertools.count(start=1)
|
||||
self.event_queues = {}
|
||||
self.request_results = {}
|
||||
@@ -102,6 +115,22 @@ class Rpc:
|
||||
self.events_thread = Thread(target=self.events_loop)
|
||||
self.events_thread.start()
|
||||
|
||||
# Perform a health-check RPC call to ensure the server started
|
||||
# successfully and the accounts directory is usable.
|
||||
try:
|
||||
system_info = self.get_system_info()
|
||||
except (JsonRpcError, Exception) as e:
|
||||
# The reader_loop already saw EOF on stdout, so the process
|
||||
# has exited and stderr is available.
|
||||
stderr = self.process.stderr.read().decode(errors="replace").strip()
|
||||
if stderr:
|
||||
raise JsonRpcError(f"RPC server failed to start: {stderr}") from e
|
||||
raise JsonRpcError(f"RPC server startup check failed: {e}") from e
|
||||
logging.info(
|
||||
"RPC server ready. Core version: %s",
|
||||
system_info.get("deltachat_core_version", "unknown"),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Terminate RPC server process and wait until the reader loop finishes."""
|
||||
self.closing = True
|
||||
@@ -132,6 +161,10 @@ class Rpc:
|
||||
except Exception:
|
||||
# Log an exception if the reader loop dies.
|
||||
logging.exception("Exception in the reader loop")
|
||||
finally:
|
||||
# Unblock any pending requests when the server closes stdout.
|
||||
for _request_id, queue in self.request_results.items():
|
||||
queue.put({"error": {"code": -32000, "message": "RPC server closed"}})
|
||||
|
||||
def writer_loop(self) -> None:
|
||||
"""Writer loop ensuring only a single thread writes requests."""
|
||||
@@ -140,7 +173,6 @@ class Rpc:
|
||||
data = (json.dumps(request) + "\n").encode()
|
||||
self.process.stdin.write(data)
|
||||
self.process.stdin.flush()
|
||||
|
||||
except Exception:
|
||||
# Log an exception if the writer loop dies.
|
||||
logging.exception("Exception in the writer loop")
|
||||
|
||||
@@ -2,32 +2,13 @@ import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from imap_tools import AND, U
|
||||
|
||||
from deltachat_rpc_client import Contact, EventType, Message
|
||||
|
||||
|
||||
def test_move_works(acfactory, direct_imap):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("DeltaChat")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
ac2.bring_online()
|
||||
|
||||
chat = ac1.create_chat(ac2)
|
||||
chat.send_text("message1")
|
||||
|
||||
# Message is moved to the movebox
|
||||
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
|
||||
|
||||
# Message is downloaded
|
||||
msg = ac2.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "message1"
|
||||
|
||||
|
||||
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
|
||||
"""When a batch of messages is moved from Inbox to another folder with a single MOVE command,
|
||||
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
|
||||
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
|
||||
messages they refer to and thus dropped.
|
||||
@@ -37,9 +18,6 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
addr, password = acfactory.get_credentials()
|
||||
ac2 = acfactory.get_unconfigured_account()
|
||||
ac2.add_or_update_transport({"addr": addr, "password": password})
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("DeltaChat")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
assert ac2.is_configured()
|
||||
|
||||
ac2.bring_online()
|
||||
@@ -55,11 +33,17 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
react_str = "\N{THUMBS UP SIGN}"
|
||||
msg1.send_reaction(react_str).wait_until_delivered()
|
||||
|
||||
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
|
||||
logging.info("moving messages to ac2's movebox folder in the reverse order")
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("Movebox")
|
||||
ac2_direct_imap.connect()
|
||||
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
|
||||
ac2_direct_imap.conn.move(uid, "DeltaChat")
|
||||
ac2_direct_imap.conn.move(uid, "Movebox")
|
||||
|
||||
logging.info("moving messages back")
|
||||
ac2_direct_imap.select_folder("Movebox")
|
||||
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()]):
|
||||
ac2_direct_imap.conn.move(uid, "INBOX")
|
||||
|
||||
logging.info("receiving messages by ac2")
|
||||
ac2.start_io()
|
||||
@@ -72,33 +56,22 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
|
||||
|
||||
|
||||
def test_move_works_on_self_sent(acfactory, direct_imap):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
|
||||
# Create and enable movebox.
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.create_folder("DeltaChat")
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.set_config("bcc_self", "1")
|
||||
ac1.bring_online()
|
||||
|
||||
chat = ac1.create_chat(ac2)
|
||||
chat.send_text("message1")
|
||||
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
|
||||
chat.send_text("message2")
|
||||
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
|
||||
chat.send_text("message3")
|
||||
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
|
||||
|
||||
|
||||
def test_moved_markseen(acfactory, direct_imap):
|
||||
def test_moved_markseen(acfactory, direct_imap, log):
|
||||
"""Test that message already moved to DeltaChat folder is marked as seen."""
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1 = acfactory.get_online_account()
|
||||
|
||||
addr, password = acfactory.get_credentials()
|
||||
ac2 = acfactory.get_unconfigured_account()
|
||||
ac2.add_or_update_transport({"addr": addr, "password": password})
|
||||
ac2.bring_online()
|
||||
|
||||
log.section("ac2: creating DeltaChat folder")
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("DeltaChat")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
ac2.set_config("delete_server_after", "0")
|
||||
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
|
||||
|
||||
ac2.add_or_update_transport({"addr": addr, "password": password, "imapFolder": "DeltaChat"})
|
||||
ac2.bring_online()
|
||||
|
||||
ac2.stop_io()
|
||||
@@ -108,6 +81,7 @@ def test_moved_markseen(acfactory, direct_imap):
|
||||
idle2.wait_for_new_message()
|
||||
|
||||
# Emulate moving of the message to DeltaChat folder by Sieve rule.
|
||||
log.section("ac2: moving message into DeltaChat folder")
|
||||
ac2_direct_imap.conn.move(["*"], "DeltaChat")
|
||||
ac2_direct_imap.select_folder("DeltaChat")
|
||||
assert len(list(ac2_direct_imap.conn.fetch("*", mark_seen=False))) == 1
|
||||
@@ -131,17 +105,11 @@ def test_moved_markseen(acfactory, direct_imap):
|
||||
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True, uid=U(1, "*")), mark_seen=False))) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mvbox_move", [True, False])
|
||||
def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
|
||||
def test_markseen_message_and_mdn(acfactory, direct_imap):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
|
||||
for ac in ac1, ac2:
|
||||
ac.set_config("delete_server_after", "0")
|
||||
if mvbox_move:
|
||||
ac_direct_imap = direct_imap(ac)
|
||||
ac_direct_imap.create_folder("DeltaChat")
|
||||
ac.set_config("mvbox_move", "1")
|
||||
ac.bring_online()
|
||||
|
||||
# Do not send BCC to self, we only want to test MDN on ac1.
|
||||
ac1.set_config("bcc_self", "0")
|
||||
@@ -150,10 +118,7 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
|
||||
msg = ac2.wait_for_incoming_msg()
|
||||
msg.mark_seen()
|
||||
|
||||
if mvbox_move:
|
||||
rex = re.compile("Marked messages [0-9]+ in folder DeltaChat as seen.")
|
||||
else:
|
||||
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
|
||||
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
|
||||
|
||||
for ac in ac1, ac2:
|
||||
while True:
|
||||
@@ -161,12 +126,11 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
|
||||
if event.kind == EventType.INFO and rex.search(event.msg):
|
||||
break
|
||||
|
||||
folder = "mvbox" if mvbox_move else "inbox"
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
|
||||
ac1_direct_imap.select_config_folder(folder)
|
||||
ac2_direct_imap.select_config_folder(folder)
|
||||
ac1_direct_imap.select_folder("INBOX")
|
||||
ac2_direct_imap.select_folder("INBOX")
|
||||
|
||||
# Check that the mdn is marked as seen
|
||||
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
|
||||
|
||||
@@ -9,10 +9,6 @@ def test_add_second_address(acfactory) -> None:
|
||||
account = acfactory.new_configured_account()
|
||||
assert len(account.list_transports()) == 1
|
||||
|
||||
# When the first transport is created,
|
||||
# mvbox_move and only_fetch_mvbox should be disabled.
|
||||
assert account.get_config("mvbox_move") == "0"
|
||||
assert account.get_config("only_fetch_mvbox") == "0"
|
||||
assert account.get_config("show_emails") == "2"
|
||||
|
||||
qr = acfactory.get_account_qr()
|
||||
@@ -32,32 +28,10 @@ def test_add_second_address(acfactory) -> None:
|
||||
account.delete_transport(second_addr)
|
||||
assert len(account.list_transports()) == 2
|
||||
|
||||
# Enabling mvbox_move or only_fetch_mvbox
|
||||
# is not allowed when multi-transport is enabled.
|
||||
for option in ["mvbox_move", "only_fetch_mvbox"]:
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.set_config(option, "1")
|
||||
|
||||
# show_emails does not matter for multi-relay, can be set to anything
|
||||
account.set_config("show_emails", "0")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
|
||||
def test_no_second_transport_with_mvbox(acfactory, key) -> None:
|
||||
"""Test that second transport cannot be configured if mvbox is used."""
|
||||
account = acfactory.new_configured_account()
|
||||
assert len(account.list_transports()) == 1
|
||||
|
||||
assert account.get_config("mvbox_move") == "0"
|
||||
assert account.get_config("only_fetch_mvbox") == "0"
|
||||
|
||||
qr = acfactory.get_account_qr()
|
||||
account.set_config(key, "1")
|
||||
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
|
||||
def test_second_transport_without_classic_emails(acfactory) -> None:
|
||||
"""Test that second transport can be configured if classic emails are not fetched."""
|
||||
account = acfactory.new_configured_account()
|
||||
@@ -147,44 +121,13 @@ def test_download_on_demand(acfactory) -> None:
|
||||
assert msg.get_snapshot().download_state == dstate
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
|
||||
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
|
||||
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
|
||||
Disabling mvbox_move is required to be able to setup a second transport.
|
||||
"""
|
||||
account = acfactory.get_unconfigured_account()
|
||||
|
||||
account.set_config("fix_is_chatmail", "1")
|
||||
account.set_config("is_chatmail", is_chatmail)
|
||||
|
||||
# The default value when the setting is unset is "1".
|
||||
# This is not changed for compatibility with old databases
|
||||
# imported from backups.
|
||||
assert account.get_config("mvbox_move") == "1"
|
||||
|
||||
qr = acfactory.get_account_qr()
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
# Once the first transport is set up,
|
||||
# mvbox_move is disabled.
|
||||
assert account.get_config("mvbox_move") == "0"
|
||||
assert account.get_config("is_chatmail") == is_chatmail
|
||||
|
||||
|
||||
def test_reconfigure_transport(acfactory) -> None:
|
||||
"""Test that reconfiguring the transport works
|
||||
even if settings not supported for multi-transport
|
||||
like mvbox_move are enabled."""
|
||||
"""Test that reconfiguring the transport works."""
|
||||
account = acfactory.get_online_account()
|
||||
account.set_config("mvbox_move", "1")
|
||||
|
||||
[transport] = account.list_transports()
|
||||
account.add_or_update_transport(transport)
|
||||
|
||||
# Reconfiguring the transport should not reset
|
||||
# the settings as if when configuring the first transport.
|
||||
assert account.get_config("mvbox_move") == "1"
|
||||
|
||||
|
||||
def test_transport_synchronization(acfactory, log) -> None:
|
||||
"""Test synchronization of transports between devices."""
|
||||
@@ -225,6 +168,9 @@ def test_transport_synchronization(acfactory, log) -> None:
|
||||
log.section("ac1 changes the primary transport")
|
||||
ac1.set_config("configured_addr", transport3["addr"])
|
||||
|
||||
# One event for updated `add_timestamp` of the new primary transport,
|
||||
# one event for the `configured_addr` update.
|
||||
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
[transport1, transport3] = ac1_clone.list_transports()
|
||||
assert ac1_clone.get_config("configured_addr") == addr3
|
||||
@@ -315,11 +261,10 @@ def test_transport_limit(acfactory) -> None:
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
|
||||
def test_message_info_imap_urls(acfactory, log) -> None:
|
||||
def test_message_info_imap_urls(acfactory) -> None:
|
||||
"""Test that message info contains IMAP URLs of where the message was received."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
log.section("Alice adds ac1 clone removes second transport")
|
||||
qr = acfactory.get_account_qr()
|
||||
for i in range(3):
|
||||
alice.add_transport_from_qr(qr)
|
||||
@@ -327,9 +272,6 @@ def test_message_info_imap_urls(acfactory, log) -> None:
|
||||
for _ in range(i + 1):
|
||||
alice.bring_online()
|
||||
|
||||
new_alice_addr = alice.list_transports()[2]["addr"]
|
||||
alice.set_config("configured_addr", new_alice_addr)
|
||||
|
||||
# Enable multi-device mode so messages are not deleted immediately.
|
||||
alice.set_config("bcc_self", "1")
|
||||
|
||||
@@ -337,12 +279,51 @@ def test_message_info_imap_urls(acfactory, log) -> None:
|
||||
# This is where he will send the message.
|
||||
bob_chat = bob.create_chat(alice)
|
||||
|
||||
# Alice changes the transport again.
|
||||
alice.set_config("configured_addr", alice.list_transports()[3]["addr"])
|
||||
# Alice switches to another transport and removes the rest of the transports.
|
||||
new_alice_addr = alice.list_transports()[1]["addr"]
|
||||
alice.set_config("configured_addr", new_alice_addr)
|
||||
removed_addrs = []
|
||||
for transport in alice.list_transports():
|
||||
if transport["addr"] != new_alice_addr:
|
||||
alice.delete_transport(transport["addr"])
|
||||
removed_addrs.append(transport["addr"])
|
||||
alice.stop_io()
|
||||
alice.start_io()
|
||||
|
||||
bob_chat.send_text("Hello!")
|
||||
|
||||
msg = alice.wait_for_incoming_msg()
|
||||
for alice_transport in alice.list_transports():
|
||||
addr = alice_transport["addr"]
|
||||
assert (addr == new_alice_addr) == (addr in msg.get_info())
|
||||
msg_info = msg.get_info()
|
||||
assert new_alice_addr in msg_info
|
||||
for removed_addr in removed_addrs:
|
||||
assert removed_addr not in msg_info
|
||||
assert f"{new_alice_addr}/INBOX" in msg_info
|
||||
|
||||
|
||||
def test_remove_primary_transport(acfactory) -> None:
|
||||
"""Test that after removing the primary relay, Alice can still receive messages."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
qr = acfactory.get_account_qr()
|
||||
|
||||
alice.add_transport_from_qr(qr)
|
||||
alice.bring_online()
|
||||
|
||||
bob_chat = bob.create_chat(alice)
|
||||
alice.create_chat(bob)
|
||||
|
||||
# Alice changes the transport.
|
||||
[transport1, transport2] = alice.list_transports()
|
||||
alice.set_config("configured_addr", transport2["addr"])
|
||||
|
||||
bob_chat.send_text("Hello!")
|
||||
msg1 = alice.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg1.text == "Hello!"
|
||||
|
||||
# Alice deletes the first transport.
|
||||
alice.delete_transport(transport1["addr"])
|
||||
alice.stop_io()
|
||||
alice.start_io()
|
||||
|
||||
bob_chat.send_text("Hello again!")
|
||||
msg2 = alice.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg2.text == "Hello again!"
|
||||
|
||||
@@ -167,12 +167,16 @@ def test_qr_securejoin_broadcast(acfactory, all_devices_online):
|
||||
assert "invited you to join this channel" in first_msg.text
|
||||
assert first_msg.is_info
|
||||
|
||||
member_added_msg = chat_msgs.pop(0).get_snapshot()
|
||||
if inviter_side:
|
||||
member_added_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
|
||||
assert member_added_msg.info_contact_id == contact_snapshot.id
|
||||
else:
|
||||
assert member_added_msg.text == "You joined the channel."
|
||||
if chat_msgs[0].get_snapshot().text == "You joined the channel.":
|
||||
member_added_msg = chat_msgs.pop(0).get_snapshot()
|
||||
else:
|
||||
member_added_msg = chat_msgs.pop(1).get_snapshot()
|
||||
assert member_added_msg.text == "You joined the channel."
|
||||
assert member_added_msg.is_info
|
||||
|
||||
hello_msg = chat_msgs.pop(0).get_snapshot()
|
||||
|
||||
@@ -13,7 +13,7 @@ import pytest
|
||||
from deltachat_rpc_client import EventType, events
|
||||
from deltachat_rpc_client.const import DownloadState, MessageState
|
||||
from deltachat_rpc_client.pytestplugin import E2EE_INFO_MSGS
|
||||
from deltachat_rpc_client.rpc import JsonRpcError
|
||||
from deltachat_rpc_client.rpc import JsonRpcError, Rpc
|
||||
|
||||
|
||||
def test_system_info(rpc) -> None:
|
||||
@@ -665,6 +665,24 @@ def test_openrpc_command_line() -> None:
|
||||
assert "methods" in openrpc
|
||||
|
||||
|
||||
def test_early_failure(tmp_path) -> None:
|
||||
"""Test that Rpc.start() raises on invalid accounts directories."""
|
||||
# A file instead of a directory.
|
||||
file_path = tmp_path / "not_a_dir"
|
||||
file_path.write_text("I am a file, not a directory")
|
||||
rpc = Rpc(accounts_dir=str(file_path))
|
||||
with pytest.raises(JsonRpcError, match="(?i)directory"):
|
||||
rpc.start()
|
||||
|
||||
# A non-empty directory that is not a deltachat accounts directory.
|
||||
non_dc_dir = tmp_path / "invalid_dir"
|
||||
non_dc_dir.mkdir()
|
||||
(non_dc_dir / "some_file").write_text("content")
|
||||
rpc = Rpc(accounts_dir=str(non_dc_dir))
|
||||
with pytest.raises(JsonRpcError, match="invalid_dir"):
|
||||
rpc.start()
|
||||
|
||||
|
||||
def test_provider_info(rpc) -> None:
|
||||
account_id = rpc.add_account()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.44.0-dev"
|
||||
"version": "2.46.0-dev"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "2.44.0-dev"
|
||||
version = "2.46.0-dev"
|
||||
license = "MPL-2.0"
|
||||
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
|
||||
readme = "README.rst"
|
||||
|
||||
@@ -522,7 +522,6 @@ class ACFactory:
|
||||
ac = self.get_unconfigured_account()
|
||||
assert "addr" in configdict and "mail_pw" in configdict, configdict
|
||||
configdict.setdefault("bcc_self", False)
|
||||
configdict.setdefault("mvbox_move", False)
|
||||
configdict.setdefault("sync_msgs", False)
|
||||
configdict.setdefault("delete_server_after", 0)
|
||||
ac.update_config(configdict)
|
||||
|
||||
@@ -52,19 +52,19 @@ class TestOfflineAccountBasic:
|
||||
|
||||
def test_set_config_int_conversion(self, acfactory):
|
||||
ac1 = acfactory.get_unconfigured_account()
|
||||
ac1.set_config("mvbox_move", False)
|
||||
assert ac1.get_config("mvbox_move") == "0"
|
||||
ac1.set_config("mvbox_move", True)
|
||||
assert ac1.get_config("mvbox_move") == "1"
|
||||
ac1.set_config("mvbox_move", 0)
|
||||
assert ac1.get_config("mvbox_move") == "0"
|
||||
ac1.set_config("mvbox_move", 1)
|
||||
assert ac1.get_config("mvbox_move") == "1"
|
||||
ac1.set_config("bcc_self", False)
|
||||
assert ac1.get_config("bcc_self") == "0"
|
||||
ac1.set_config("bcc_self", True)
|
||||
assert ac1.get_config("bcc_self") == "1"
|
||||
ac1.set_config("bcc_self", 0)
|
||||
assert ac1.get_config("bcc_self") == "0"
|
||||
ac1.set_config("bcc_self", 1)
|
||||
assert ac1.get_config("bcc_self") == "1"
|
||||
|
||||
def test_update_config(self, acfactory):
|
||||
ac1 = acfactory.get_unconfigured_account()
|
||||
ac1.update_config({"mvbox_move": False})
|
||||
assert ac1.get_config("mvbox_move") == "0"
|
||||
ac1.update_config({"bcc_self": True})
|
||||
assert ac1.get_config("bcc_self") == "1"
|
||||
|
||||
def test_has_bccself(self, acfactory):
|
||||
ac1 = acfactory.get_unconfigured_account()
|
||||
@@ -558,6 +558,12 @@ class TestOfflineChat:
|
||||
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
|
||||
assert os.path.exists(messages[1 + E2EE_INFO_MSGS].filename)
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="We didn't find a way to correctly reset an account after a failed import attempt "
|
||||
"while simultaneously making sure "
|
||||
"that the password of an encrypted account survives a failed import attempt. "
|
||||
"Since passphrases are not really supported anymore, we decided to just disable the test.",
|
||||
)
|
||||
def test_import_encrypted_bak_into_encrypted_acct(self, acfactory, tmp_path):
|
||||
"""
|
||||
Test that account passphrase isn't lost if backup failed to be imported.
|
||||
|
||||
@@ -111,7 +111,7 @@ def test_dc_close_events(acfactory):
|
||||
register_global_plugin(ShutdownPlugin())
|
||||
assert hasattr(ac1, "_dc_context")
|
||||
ac1.shutdown()
|
||||
shutdowns.get(timeout=2)
|
||||
shutdowns.get()
|
||||
|
||||
|
||||
def test_wrong_db(tmp_path):
|
||||
@@ -221,7 +221,7 @@ def test_logged_ac_process_ffi_failure(acfactory):
|
||||
|
||||
# cause any event eg contact added/changed
|
||||
ac1.create_contact("something@example.org")
|
||||
res = cap.get(timeout=10)
|
||||
res = cap.get()
|
||||
assert "ac_process_ffi_event" in res
|
||||
assert "ZeroDivisionError" in res
|
||||
assert "Traceback" in res
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026-02-24
|
||||
2026-03-14
|
||||
@@ -7,7 +7,7 @@ set -euo pipefail
|
||||
#
|
||||
# Avoid using rustup here as it depends on reading /proc/self/exe and
|
||||
# has problems running under QEMU.
|
||||
RUST_VERSION=1.93.0
|
||||
RUST_VERSION=1.94.0
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu
|
||||
|
||||
@@ -47,11 +47,11 @@ pub struct Aheader {
|
||||
pub public_key: SignedPublicKey,
|
||||
pub prefer_encrypt: EncryptPreference,
|
||||
|
||||
// Whether `_verified` attribute is present.
|
||||
//
|
||||
// `_verified` attribute is an extension to `Autocrypt-Gossip`
|
||||
// header that is used to tell that the sender
|
||||
// marked this key as verified.
|
||||
/// Whether `_verified` attribute is present.
|
||||
///
|
||||
/// `_verified` attribute is an extension to `Autocrypt-Gossip`
|
||||
/// header that is used to tell that the sender
|
||||
/// marked this key as verified.
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
@@ -108,13 +108,11 @@ impl FromStr for Aheader {
|
||||
.remove("keydata")
|
||||
.context("keydata attribute is not found")
|
||||
.and_then(|raw| {
|
||||
SignedPublicKey::from_base64(&raw).context("autocrypt key cannot be decoded")
|
||||
})
|
||||
.and_then(|key| {
|
||||
key.verify_bindings()
|
||||
.and(Ok(key))
|
||||
.context("Autocrypt key cannot be verified")
|
||||
SignedPublicKey::from_base64(&raw).context("Autocrypt key cannot be decoded")
|
||||
})?;
|
||||
public_key
|
||||
.verify_bindings()
|
||||
.context("Autocrypt key cannot be verified")?;
|
||||
|
||||
let prefer_encrypt = attributes
|
||||
.remove("prefer-encrypt")
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::context::{Context, WeakContext};
|
||||
use crate::events::EventType;
|
||||
use crate::headerdef::HeaderDef;
|
||||
use crate::log::warn;
|
||||
use crate::message::{Message, MsgId, Viewtype};
|
||||
use crate::message::{Message, MsgId, Viewtype, markseen_msgs};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::net::dns::lookup_host_with_cache;
|
||||
use crate::param::Param;
|
||||
@@ -249,6 +249,7 @@ impl Context {
|
||||
if chat.is_contact_request() {
|
||||
chat.id.accept(self).await?;
|
||||
}
|
||||
markseen_msgs(self, vec![call_id]).await?;
|
||||
|
||||
// send an acceptance message around: to the caller as well as to the other devices of the callee
|
||||
let mut msg = Message {
|
||||
@@ -265,6 +266,7 @@ impl Context {
|
||||
self.emit_event(EventType::IncomingCallAccepted {
|
||||
msg_id: call.msg.id,
|
||||
chat_id: call.msg.chat_id,
|
||||
from_this_device: true,
|
||||
});
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
Ok(())
|
||||
@@ -283,6 +285,7 @@ impl Context {
|
||||
if !call.is_accepted() {
|
||||
if call.is_incoming() {
|
||||
call.mark_as_ended(self).await?;
|
||||
markseen_msgs(self, vec![call_id]).await?;
|
||||
let declined_call_str = stock_str::declined_call(self).await;
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
} else {
|
||||
@@ -430,6 +433,7 @@ impl Context {
|
||||
self.emit_event(EventType::IncomingCallAccepted {
|
||||
msg_id: call.msg.id,
|
||||
chat_id: call.msg.chat_id,
|
||||
from_this_device: false,
|
||||
});
|
||||
} else {
|
||||
let accept_call_info = mime_message
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::chat::forward_msgs;
|
||||
use crate::config::Config;
|
||||
use crate::constants::DC_CHAT_ID_TRASH;
|
||||
use crate::message::MessageState;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::test_utils::{TestContext, TestContextManager};
|
||||
|
||||
@@ -115,9 +116,28 @@ async fn accept_call() -> Result<CallSetup> {
|
||||
// Bob accepts the incoming call
|
||||
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
|
||||
.await?;
|
||||
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
|
||||
// Bob sends an MDN to Alice.
|
||||
assert_eq!(
|
||||
bob.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
|
||||
(bob_call.id, bob_call.from_id)
|
||||
)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
assert_text(&bob, bob_call.id, "Incoming video call").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
|
||||
.get_matching(|evt| {
|
||||
matches!(
|
||||
evt,
|
||||
EventType::IncomingCallAccepted {
|
||||
from_this_device: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let sent2 = bob.pop_sent_msg().await;
|
||||
let info = bob
|
||||
@@ -131,7 +151,15 @@ async fn accept_call() -> Result<CallSetup> {
|
||||
bob2.recv_msg_trash(&sent2).await;
|
||||
assert_text(&bob, bob_call.id, "Incoming video call").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
|
||||
.get_matching(|evt| {
|
||||
matches!(
|
||||
evt,
|
||||
EventType::IncomingCallAccepted {
|
||||
from_this_device: false,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let info = bob2
|
||||
.load_call_by_id(bob2_call.id)
|
||||
@@ -200,9 +228,20 @@ async fn test_accept_call_callee_ends() -> Result<()> {
|
||||
bob2_call,
|
||||
..
|
||||
} = accept_call().await?;
|
||||
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
|
||||
|
||||
// Bob has accepted the call and also ends it
|
||||
bob.end_call(bob_call.id).await?;
|
||||
// Bob sends an MDN to Alice.
|
||||
assert_eq!(
|
||||
bob.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
|
||||
(bob_call.id, bob_call.from_id)
|
||||
)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -328,8 +367,18 @@ async fn test_callee_rejects_call() -> Result<()> {
|
||||
} = setup_call().await?;
|
||||
|
||||
// Bob has accepted Alice before, but does not want to talk with Alice
|
||||
bob_call.chat_id.accept(&bob).await?;
|
||||
bob.end_call(bob_call.id).await?;
|
||||
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
|
||||
// Bob sends an MDN to Alice.
|
||||
assert_eq!(
|
||||
bob.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
|
||||
(bob_call.id, bob_call.from_id)
|
||||
)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
assert_text(&bob, bob_call.id, "Declined call").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -370,6 +419,35 @@ async fn test_callee_rejects_call() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_callee_sees_contact_request_call() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat = alice.create_chat(bob).await;
|
||||
alice
|
||||
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
|
||||
.await?;
|
||||
let sent1 = alice.pop_sent_msg().await;
|
||||
let bob_call = bob.recv_msg(&sent1).await;
|
||||
// Bob can't end_call() because the contact request isn't accepted, but he can mark the call as
|
||||
// seen.
|
||||
markseen_msgs(bob, vec![bob_call.id]).await?;
|
||||
assert_eq!(bob_call.id.get_state(bob).await?, MessageState::InSeen);
|
||||
// Bob sends an MDN only to self so that an unaccepted contact can't know anything.
|
||||
assert_eq!(
|
||||
bob.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
|
||||
(bob_call.id, ContactId::SELF)
|
||||
)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_caller_cancels_call() -> Result<()> {
|
||||
// Alice calls Bob
|
||||
|
||||
105
src/chat.rs
105
src/chat.rs
@@ -1,7 +1,7 @@
|
||||
//! # Chat module.
|
||||
|
||||
use std::cmp;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::io::Cursor;
|
||||
use std::marker::Sync;
|
||||
@@ -42,6 +42,7 @@ use crate::message::{self, Message, MessageState, MsgId, Viewtype};
|
||||
use crate::mimefactory::{MimeFactory, RenderedEmail};
|
||||
use crate::mimeparser::SystemMessage;
|
||||
use crate::param::{Param, Params};
|
||||
use crate::pgp::addresses_from_public_key;
|
||||
use crate::receive_imf::ReceivedMsg;
|
||||
use crate::smtp::{self, send_msg_to_smtp};
|
||||
use crate::stock_str;
|
||||
@@ -257,7 +258,11 @@ impl ChatId {
|
||||
ChatIdBlocked::get_for_contact(context, contact_id, create_blocked)
|
||||
.await
|
||||
.map(|chat| chat.id)?;
|
||||
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat).await?;
|
||||
if create_blocked != Blocked::Yes {
|
||||
info!(context, "Scale up origin of {contact_id} to CreateChat.");
|
||||
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat)
|
||||
.await?;
|
||||
}
|
||||
chat_id
|
||||
} else {
|
||||
warn!(
|
||||
@@ -471,7 +476,7 @@ impl ChatId {
|
||||
|
||||
/// Adds message "Messages are end-to-end encrypted".
|
||||
pub(crate) async fn add_e2ee_notice(self, context: &Context, timestamp: i64) -> Result<()> {
|
||||
let text = stock_str::messages_e2e_encrypted(context).await;
|
||||
let text = stock_str::messages_e2ee_info_msg(context).await;
|
||||
add_info_msg_with_cmd(
|
||||
context,
|
||||
self,
|
||||
@@ -1153,7 +1158,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
|
||||
return Ok(stock_str::encr_none(context).await);
|
||||
}
|
||||
|
||||
let mut ret = stock_str::messages_e2e_encrypted(context).await + "\n";
|
||||
let mut ret = stock_str::messages_are_e2ee(context).await + "\n";
|
||||
|
||||
for &contact_id in get_chat_contacts(context, self)
|
||||
.await?
|
||||
@@ -1170,8 +1175,13 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
|
||||
let fingerprint = contact
|
||||
.fingerprint()
|
||||
.context("Contact does not have a fingerprint in encrypted chat")?;
|
||||
if contact.public_key(context).await?.is_some() {
|
||||
ret += &format!("\n{addr}\n{fingerprint}\n");
|
||||
if let Some(public_key) = contact.public_key(context).await? {
|
||||
if let Some(relay_addrs) = addresses_from_public_key(&public_key) {
|
||||
let relays = relay_addrs.join(",");
|
||||
ret += &format!("\n{addr}({relays})\n{fingerprint}\n");
|
||||
} else {
|
||||
ret += &format!("\n{addr}\n{fingerprint}\n");
|
||||
}
|
||||
} else {
|
||||
ret += &format!("\n{addr}\n(key missing)\n{fingerprint}\n");
|
||||
}
|
||||
@@ -1772,16 +1782,6 @@ impl Chat {
|
||||
.set_i64(Param::GroupNameTimestamp, msg.timestamp_sort)
|
||||
.set_i64(Param::GroupDescriptionTimestamp, msg.timestamp_sort);
|
||||
self.update_param(context).await?;
|
||||
// TODO: Remove this compat code needed because Core <= v1.143:
|
||||
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also
|
||||
// send them when the group is promoted.
|
||||
// - doesn't sync QR code tokens for unpromoted groups and the group might be created
|
||||
// before an upgrade.
|
||||
context
|
||||
.sync_qr_code_tokens(Some(self.grpid.as_str()))
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
}
|
||||
|
||||
let is_bot = context.get_config_bool(Config::Bot).await?;
|
||||
@@ -3894,8 +3894,6 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let sync_qr_code_tokens;
|
||||
if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 {
|
||||
let smeared_time = smeared_time(context);
|
||||
chat.param
|
||||
@@ -3903,11 +3901,7 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
.set_i64(Param::GroupNameTimestamp, smeared_time)
|
||||
.set_i64(Param::GroupDescriptionTimestamp, smeared_time);
|
||||
chat.update_param(context).await?;
|
||||
sync_qr_code_tokens = true;
|
||||
} else {
|
||||
sync_qr_code_tokens = false;
|
||||
}
|
||||
|
||||
if context.is_self_addr(contact.get_addr()).await? {
|
||||
// ourself is added using ContactId::SELF, do not add this address explicitly.
|
||||
// if SELF is not in the group, members cannot be added at all.
|
||||
@@ -3956,20 +3950,6 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
send_msg(context, chat_id, &mut msg).await?;
|
||||
|
||||
sync = Nosync;
|
||||
// TODO: Remove this compat code needed because Core <= v1.143:
|
||||
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also send
|
||||
// them when the group is promoted.
|
||||
// - doesn't sync QR code tokens for unpromoted groups and the group might be created before
|
||||
// an upgrade.
|
||||
if sync_qr_code_tokens
|
||||
&& context
|
||||
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
|
||||
.await
|
||||
.log_err(context)
|
||||
.is_ok()
|
||||
{
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
}
|
||||
}
|
||||
context.emit_event(EventType::ChatModified(chat_id));
|
||||
if sync.into() {
|
||||
@@ -4263,9 +4243,7 @@ async fn set_chat_description_ex(
|
||||
|
||||
if chat.is_promoted() {
|
||||
let mut msg = Message::new(Viewtype::Text);
|
||||
msg.text =
|
||||
"[Chat description changed. To see this and other new features, please update the app]"
|
||||
.to_string();
|
||||
msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await;
|
||||
msg.param.set_cmd(SystemMessage::GroupDescriptionChanged);
|
||||
|
||||
msg.id = send_msg(context, chat_id, &mut msg).await?;
|
||||
@@ -4350,8 +4328,11 @@ async fn rename_ex(
|
||||
&& sanitize_single_line(&chat.name) != new_name
|
||||
{
|
||||
msg.viewtype = Viewtype::Text;
|
||||
msg.text =
|
||||
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await;
|
||||
msg.text = if chat.typ == Chattype::OutBroadcast {
|
||||
stock_str::msg_broadcast_name_changed(context, &chat.name, &new_name).await
|
||||
} else {
|
||||
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await
|
||||
};
|
||||
msg.param.set_cmd(SystemMessage::GroupNameChanged);
|
||||
if !chat.name.is_empty() {
|
||||
msg.param.set(Param::Arg, &chat.name);
|
||||
@@ -4412,7 +4393,11 @@ pub async fn set_chat_profile_image(
|
||||
if new_image.is_empty() {
|
||||
chat.param.remove(Param::ProfileImage);
|
||||
msg.param.remove(Param::Arg);
|
||||
msg.text = stock_str::msg_grp_img_deleted(context, ContactId::SELF).await;
|
||||
msg.text = if chat.typ == Chattype::OutBroadcast {
|
||||
stock_str::msg_broadcast_img_changed(context).await
|
||||
} else {
|
||||
stock_str::msg_grp_img_deleted(context, ContactId::SELF).await
|
||||
};
|
||||
} else {
|
||||
let mut image_blob = BlobObject::create_and_deduplicate(
|
||||
context,
|
||||
@@ -4422,7 +4407,11 @@ pub async fn set_chat_profile_image(
|
||||
image_blob.recode_to_avatar_size(context).await?;
|
||||
chat.param.set(Param::ProfileImage, image_blob.as_name());
|
||||
msg.param.set(Param::Arg, image_blob.as_name());
|
||||
msg.text = stock_str::msg_grp_img_changed(context, ContactId::SELF).await;
|
||||
msg.text = if chat.typ == Chattype::OutBroadcast {
|
||||
stock_str::msg_broadcast_img_changed(context).await
|
||||
} else {
|
||||
stock_str::msg_grp_img_changed(context, ContactId::SELF).await
|
||||
};
|
||||
}
|
||||
chat.update_param(context).await?;
|
||||
if chat.is_promoted() {
|
||||
@@ -4664,7 +4653,6 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
|
||||
}
|
||||
msg_state => bail!("Unexpected message state {msg_state}"),
|
||||
}
|
||||
msg.timestamp_sort = create_smeared_timestamp(context);
|
||||
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -5060,18 +5048,18 @@ async fn set_contacts_by_fingerprints(
|
||||
matches!(chat.typ, Chattype::Group | Chattype::OutBroadcast),
|
||||
"{id} is not a group or broadcast",
|
||||
);
|
||||
let mut contacts = HashSet::new();
|
||||
let mut contacts = BTreeSet::new();
|
||||
for (fingerprint, addr) in fingerprint_addrs {
|
||||
let contact = Contact::add_or_lookup_ex(context, "", addr, fingerprint, Origin::Hidden)
|
||||
.await?
|
||||
.0;
|
||||
contacts.insert(contact);
|
||||
}
|
||||
let contacts_old = HashSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
|
||||
let contacts_old = BTreeSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
|
||||
if contacts == contacts_old {
|
||||
return Ok(());
|
||||
}
|
||||
context
|
||||
let broadcast_contacts_added = context
|
||||
.sql
|
||||
.transaction(move |transaction| {
|
||||
// For broadcast channels, we only add members,
|
||||
@@ -5088,12 +5076,31 @@ async fn set_contacts_by_fingerprints(
|
||||
let mut statement = transaction.prepare(
|
||||
"INSERT OR IGNORE INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)",
|
||||
)?;
|
||||
let mut broadcast_contacts_added = Vec::new();
|
||||
for contact_id in &contacts {
|
||||
statement.execute((id, contact_id))?;
|
||||
if statement.execute((id, contact_id))? > 0 && chat.typ == Chattype::OutBroadcast {
|
||||
broadcast_contacts_added.push(*contact_id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(broadcast_contacts_added)
|
||||
})
|
||||
.await?;
|
||||
let timestamp = smeared_time(context);
|
||||
for added_id in broadcast_contacts_added {
|
||||
let msg = stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED).await;
|
||||
add_info_msg_with_cmd(
|
||||
context,
|
||||
id,
|
||||
&msg,
|
||||
SystemMessage::MemberAddedToGroup,
|
||||
Some(timestamp),
|
||||
timestamp,
|
||||
None,
|
||||
Some(ContactId::SELF),
|
||||
Some(added_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
context.emit_event(EventType::ChatModified(id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2641,7 +2641,7 @@ async fn test_resend_own_message() -> Result<()> {
|
||||
);
|
||||
let msg_from = Contact::get_by_id(&fiona, msg.get_from_id()).await?;
|
||||
assert_eq!(msg_from.get_addr(), "alice@example.org");
|
||||
assert!(sent1_ts_sent < msg.timestamp_sent);
|
||||
assert!(sent1_ts_sent == msg.timestamp_sent);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2731,27 +2731,24 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
|
||||
join_securejoin(charlie, &qr).await.unwrap();
|
||||
|
||||
let request = charlie.pop_sent_msg().await;
|
||||
assert_eq!(request.recipients, "alice@example.org charlie@example.net");
|
||||
assert_eq!(request.recipients, "alice@example.org");
|
||||
|
||||
alice.recv_msg_trash(&request).await;
|
||||
}
|
||||
|
||||
tcm.section("Alice sends auth-required");
|
||||
tcm.section("Alice sends vc-pubkey");
|
||||
{
|
||||
let auth_required = alice.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
auth_required.recipients,
|
||||
"charlie@example.net alice@example.org"
|
||||
);
|
||||
let parsed = charlie.parse_msg(&auth_required).await;
|
||||
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
|
||||
assert!(parsed.decoded_data_contains("charlie@example.net"));
|
||||
let vc_pubkey = alice.pop_sent_msg().await;
|
||||
assert_eq!(vc_pubkey.recipients, "charlie@example.net");
|
||||
let parsed = charlie.parse_msg(&vc_pubkey).await;
|
||||
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_none());
|
||||
assert_eq!(parsed.decoded_data_contains("charlie@example.net"), false);
|
||||
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
|
||||
|
||||
let parsed_by_bob = bob.parse_msg(&auth_required).await;
|
||||
let parsed_by_bob = bob.parse_msg(&vc_pubkey).await;
|
||||
assert!(parsed_by_bob.decrypting_failed);
|
||||
|
||||
charlie.recv_msg_trash(&auth_required).await;
|
||||
charlie.recv_msg_trash(&vc_pubkey).await;
|
||||
}
|
||||
|
||||
tcm.section("Charlie sends request-with-auth");
|
||||
@@ -2992,27 +2989,49 @@ async fn test_broadcast_recipients_sync1() -> Result<()> {
|
||||
alice1.recv_msg_trash(&request).await;
|
||||
alice2.recv_msg_trash(&request).await;
|
||||
|
||||
let auth_required = alice1.pop_sent_msg().await;
|
||||
charlie.recv_msg_trash(&auth_required).await;
|
||||
alice2.recv_msg_trash(&auth_required).await;
|
||||
let vc_pubkey = alice1.pop_sent_msg().await;
|
||||
charlie.recv_msg_trash(&vc_pubkey).await;
|
||||
|
||||
let request_with_auth = charlie.pop_sent_msg().await;
|
||||
alice1.recv_msg_trash(&request_with_auth).await;
|
||||
alice2.recv_msg_trash(&request_with_auth).await;
|
||||
|
||||
let member_added = alice1.pop_sent_msg().await;
|
||||
let a2_member_added = alice2.recv_msg(&member_added).await;
|
||||
let a2_charlie_added = alice2.recv_msg(&member_added).await;
|
||||
let _c_member_added = charlie.recv_msg(&member_added).await;
|
||||
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
|
||||
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_charlie_added.id);
|
||||
|
||||
// Alice1 will now sync the full member list to Alice2:
|
||||
sync(alice1, alice2).await;
|
||||
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
|
||||
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_member_added.id);
|
||||
|
||||
let a2_bob_contact = alice2.add_or_lookup_contact_id(bob).await;
|
||||
let a2_charlie_contact = alice2.add_or_lookup_contact_id(charlie).await;
|
||||
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
|
||||
let msg_id = a2_chatlist.get_msg_id(0)?.unwrap();
|
||||
let a2_bob_added = Message::load_from_db(alice2, msg_id).await?;
|
||||
assert_ne!(a2_bob_added.id, a2_charlie_added.id);
|
||||
assert_eq!(
|
||||
a2_bob_added.text,
|
||||
stock_str::msg_add_member_local(alice2, a2_bob_contact, ContactId::UNDEFINED).await
|
||||
);
|
||||
assert_eq!(a2_bob_added.from_id, ContactId::SELF);
|
||||
assert_eq!(
|
||||
a2_bob_added.param.get_cmd(),
|
||||
SystemMessage::MemberAddedToGroup
|
||||
);
|
||||
assert_eq!(
|
||||
ContactId::new(
|
||||
a2_bob_added
|
||||
.param
|
||||
.get_int(Param::ContactAddedRemoved)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap()
|
||||
),
|
||||
a2_bob_contact
|
||||
);
|
||||
|
||||
let a2_chat_members = get_chat_contacts(alice2, a2_member_added.chat_id).await?;
|
||||
let a2_chat_members = get_chat_contacts(alice2, a2_charlie_added.chat_id).await?;
|
||||
assert!(a2_chat_members.contains(&a2_bob_contact));
|
||||
assert!(a2_chat_members.contains(&a2_charlie_contact));
|
||||
assert_eq!(a2_chat_members.len(), 2);
|
||||
@@ -3118,7 +3137,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupNameChanged);
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
r#"Group name changed from "My Channel" to "New Channel name" by Alice."#
|
||||
r#"Channel name changed from "My Channel" to "New Channel name"."#
|
||||
);
|
||||
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
|
||||
assert_eq!(bob_chat.name, "New Channel name");
|
||||
@@ -3135,7 +3154,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert!(rcvd.get_override_sender_name().is_none());
|
||||
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupImageChanged);
|
||||
assert_eq!(rcvd.text, "Group image changed by Alice.");
|
||||
assert_eq!(rcvd.text, "Channel image changed.");
|
||||
assert_eq!(rcvd.chat_id, bob_chat.id);
|
||||
|
||||
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
|
||||
@@ -3158,29 +3177,59 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_description_basic() {
|
||||
test_chat_description("", false).await.unwrap()
|
||||
test_chat_description("", false, Chattype::Group)
|
||||
.await
|
||||
.unwrap();
|
||||
// Don't test with broadcast channels,
|
||||
// because broadcast channels can only be joined via a QR code
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_description_unpromoted_description() {
|
||||
test_chat_description("Unpromoted description in the beginning", false)
|
||||
.await
|
||||
.unwrap()
|
||||
test_chat_description(
|
||||
"Unpromoted description in the beginning",
|
||||
false,
|
||||
Chattype::Group,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Don't test with broadcast channels,
|
||||
// because broadcast channels can only be joined via a QR code
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_description_qr() {
|
||||
test_chat_description("", true).await.unwrap()
|
||||
test_chat_description("", true, Chattype::Group)
|
||||
.await
|
||||
.unwrap();
|
||||
test_chat_description("", true, Chattype::OutBroadcast)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_description_unpromoted_description_qr() {
|
||||
test_chat_description("Unpromoted description in the beginning", true)
|
||||
.await
|
||||
.unwrap()
|
||||
test_chat_description(
|
||||
"Unpromoted description in the beginning",
|
||||
true,
|
||||
Chattype::Group,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
test_chat_description(
|
||||
"Unpromoted description in the beginning",
|
||||
true,
|
||||
Chattype::OutBroadcast,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn test_chat_description(initial_description: &str, join_via_qr: bool) -> Result<()> {
|
||||
async fn test_chat_description(
|
||||
initial_description: &str,
|
||||
join_via_qr: bool,
|
||||
chattype: Chattype,
|
||||
) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
@@ -3190,12 +3239,29 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
|
||||
alice2.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
|
||||
tcm.section("Create a group chat, and add Bob");
|
||||
let alice_chat_id = create_group(alice, "My Group").await?;
|
||||
let alice_chat_id = if chattype == Chattype::Group {
|
||||
create_group(alice, "My Group").await?
|
||||
} else {
|
||||
create_broadcast(alice, "My Channel".to_string()).await?
|
||||
};
|
||||
sync(alice, alice2).await;
|
||||
|
||||
if !initial_description.is_empty() {
|
||||
set_chat_description(alice, alice_chat_id, initial_description).await?;
|
||||
|
||||
if chattype == Chattype::OutBroadcast {
|
||||
// Broadcast channels are always promoted, so, a message is sent:
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
sent.load_from_db().await.text,
|
||||
"You changed the chat description."
|
||||
);
|
||||
let rcvd = alice2.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.text, "You changed the chat description.");
|
||||
} else {
|
||||
sync(alice, alice2).await;
|
||||
}
|
||||
}
|
||||
sync(alice, alice2).await;
|
||||
|
||||
let alice2_chat_id = get_chat_id_by_grpid(
|
||||
alice2,
|
||||
@@ -3223,7 +3289,7 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
|
||||
initial_description
|
||||
);
|
||||
|
||||
for description in ["This is a cool group", "", "ä ẟ 😂"] {
|
||||
for description in ["This is a cool chat", "", "ä ẟ 😂"] {
|
||||
tcm.section(&format!(
|
||||
"Alice sets the chat description to '{description}'"
|
||||
));
|
||||
@@ -3231,10 +3297,15 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
sent.load_from_db().await.text,
|
||||
"[Chat description changed. To see this and other new features, please update the app]"
|
||||
"You changed the chat description."
|
||||
);
|
||||
|
||||
tcm.section("Bob receives the description change");
|
||||
let parsed = MimeMessage::from_bytes(bob, sent.payload().as_bytes()).await?;
|
||||
assert_eq!(
|
||||
parsed.parts[0].msg,
|
||||
"[Chat description changed. To see this and other new features, please update the app]"
|
||||
);
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged);
|
||||
assert_eq!(rcvd.text, "Chat description changed by alice@example.org.");
|
||||
@@ -3329,14 +3400,17 @@ async fn test_broadcast_joining_golden() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let alice_bob_contact = alice.add_or_lookup_contact_no_key(bob).await;
|
||||
let private_chat = ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
|
||||
.await?
|
||||
.unwrap();
|
||||
// The 1:1 chat with Bob should not be visible to the user:
|
||||
assert_eq!(private_chat.blocked, Blocked::Yes);
|
||||
assert!(
|
||||
ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
let private_chat_id =
|
||||
ChatId::create_for_contact_with_blocked(alice, alice_bob_contact.id, Blocked::Not).await?;
|
||||
alice
|
||||
.golden_test_chat(
|
||||
private_chat.id,
|
||||
private_chat_id,
|
||||
"test_broadcast_joining_golden_private_chat",
|
||||
)
|
||||
.await;
|
||||
@@ -3613,16 +3687,13 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
join_securejoin(bob0, &qr).await.unwrap();
|
||||
|
||||
let request = bob0.pop_sent_msg().await;
|
||||
assert_eq!(request.recipients, "alice@example.org bob@example.net");
|
||||
assert_eq!(request.recipients, "alice@example.org");
|
||||
|
||||
alice.recv_msg_trash(&request).await;
|
||||
let auth_required = alice.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
auth_required.recipients,
|
||||
"bob@example.net alice@example.org"
|
||||
);
|
||||
let vc_pubkey = alice.pop_sent_msg().await;
|
||||
assert_eq!(vc_pubkey.recipients, "bob@example.net");
|
||||
|
||||
bob0.recv_msg_trash(&auth_required).await;
|
||||
bob0.recv_msg_trash(&vc_pubkey).await;
|
||||
let request_with_auth = bob0.pop_sent_msg().await;
|
||||
assert_eq!(
|
||||
request_with_auth.recipients,
|
||||
@@ -3638,7 +3709,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup);
|
||||
|
||||
tcm.section("Bob's second device also receives these messages");
|
||||
bob1.recv_msg_trash(&auth_required).await;
|
||||
bob1.recv_msg_trash(&vc_pubkey).await;
|
||||
bob1.recv_msg_trash(&request_with_auth).await;
|
||||
bob1.recv_msg(&member_added).await;
|
||||
|
||||
@@ -3668,7 +3739,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
|
||||
let leave_msg = bob0.pop_sent_msg().await;
|
||||
let parsed = MimeMessage::from_bytes(bob1, leave_msg.payload().as_bytes()).await?;
|
||||
assert_eq!(parsed.parts[0].msg, "I left the group.");
|
||||
assert_eq!(parsed.parts[0].msg, "bob@example.net left the group.");
|
||||
|
||||
let rcvd = bob1.recv_msg(&leave_msg).await;
|
||||
|
||||
@@ -3735,7 +3806,7 @@ async fn test_only_broadcast_owner_can_send_1() -> Result<()> {
|
||||
"Bob receives an answer, but shows it in 1:1 chat because of a fingerprint mismatch",
|
||||
);
|
||||
let rcvd = bob.recv_msg(&member_added).await;
|
||||
assert_eq!(rcvd.text, "I added member bob@example.net.");
|
||||
assert_eq!(rcvd.text, "Member bob@example.net was added.");
|
||||
|
||||
let bob_alice_chat_id = bob.get_chat(alice).await.id;
|
||||
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
|
||||
@@ -3785,6 +3856,7 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
|
||||
tcm.section("Now, Alice's fingerprint changes");
|
||||
|
||||
alice.sql.execute("DELETE FROM keypairs", ()).await?;
|
||||
*alice.self_public_key.lock().await = None;
|
||||
alice
|
||||
.sql
|
||||
.execute("DELETE FROM config WHERE keyname='key_id'", ())
|
||||
@@ -3795,14 +3867,20 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
|
||||
.self_fingerprint
|
||||
.take();
|
||||
|
||||
tcm.section(
|
||||
"Alice sends a message, which is not put into the broadcast chat but into a 1:1 chat",
|
||||
);
|
||||
tcm.section("Alice sends a message, which is trashed");
|
||||
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.text, "Hi");
|
||||
let bob_alice_chat_id = bob.get_chat(alice).await.id;
|
||||
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
|
||||
bob.recv_msg_trash(&sent).await;
|
||||
let EventType::Warning(warning) = bob
|
||||
.evtracker
|
||||
.get_matching(|ev| matches!(ev, EventType::Warning(_)))
|
||||
.await
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
warning.contains("This sender is not allowed to encrypt with this secret key"),
|
||||
"Wrong warning: {warning}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3837,7 +3915,7 @@ async fn test_sync_broadcast_avatar_and_name() -> Result<()> {
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
r#"You changed group name from "foo" to "New name"."#
|
||||
r#"Channel name changed from "foo" to "New name"."#
|
||||
);
|
||||
|
||||
let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?;
|
||||
@@ -3851,7 +3929,7 @@ async fn test_sync_broadcast_avatar_and_name() -> Result<()> {
|
||||
let rcvd = alice1.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.chat_id, a1_broadcast_id);
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupImageChanged);
|
||||
assert_eq!(rcvd.text, "You changed the group image.");
|
||||
assert_eq!(rcvd.text, "Channel image changed.");
|
||||
|
||||
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
|
||||
let avatar = a1_broadcast_chat.get_profile_image(alice1).await?.unwrap();
|
||||
@@ -3871,6 +3949,7 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
||||
let grpid = "grpid";
|
||||
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
let bob_alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
|
||||
|
||||
tcm.section("Create a broadcast channel with Bob, and send a message");
|
||||
let alice_chat_id = create_out_broadcast_ex(
|
||||
@@ -3894,6 +3973,7 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
save_broadcast_secret(bob, bob_chat_id, secret).await?;
|
||||
add_to_chat_contacts_table(bob, time(), bob_chat_id, &[bob_alice_contact_id]).await?;
|
||||
|
||||
let sent = alice
|
||||
.send_text(alice_chat_id, "Symmetrically encrypted message")
|
||||
@@ -3967,7 +4047,7 @@ async fn test_chat_get_encryption_info() -> Result<()> {
|
||||
chat_id.get_encryption_info(alice).await?,
|
||||
"Messages are end-to-end encrypted.\n\
|
||||
\n\
|
||||
bob@example.net\n\
|
||||
bob@example.net(bob@example.net)\n\
|
||||
CCCB 5AA9 F6E1 141C 9431\n\
|
||||
65F1 DB18 B18C BCF7 0487"
|
||||
);
|
||||
@@ -3977,11 +4057,11 @@ async fn test_chat_get_encryption_info() -> Result<()> {
|
||||
chat_id.get_encryption_info(alice).await?,
|
||||
"Messages are end-to-end encrypted.\n\
|
||||
\n\
|
||||
fiona@example.net\n\
|
||||
fiona@example.net(fiona@example.net)\n\
|
||||
C8BA 50BF 4AC1 2FAF 38D7\n\
|
||||
F657 DDFC 8E9F 3C79 9195\n\
|
||||
\n\
|
||||
bob@example.net\n\
|
||||
bob@example.net(bob@example.net)\n\
|
||||
CCCB 5AA9 F6E1 141C 9431\n\
|
||||
65F1 DB18 B18C BCF7 0487"
|
||||
);
|
||||
@@ -4653,6 +4733,10 @@ async fn test_sync_broadcast_and_send_message() -> Result<()> {
|
||||
vec![a2b_contact_id]
|
||||
);
|
||||
|
||||
// alice2's smeared clock may be behind alice1's one, so we need to work around "hi" appearing
|
||||
// before "You joined the channel." for bob. alice1 makes 3 more calls of
|
||||
// create_smeared_timestamp() than alice2 does as of 2026-03-10.
|
||||
SystemTime::shift(Duration::from_secs(3));
|
||||
tcm.section("Alice's second device sends a message to the channel");
|
||||
let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await;
|
||||
let msg = bob.recv_msg(&sent_msg).await;
|
||||
@@ -4717,7 +4801,7 @@ async fn test_sync_name() -> Result<()> {
|
||||
assert_eq!(rcvd.to_id, ContactId::SELF);
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
"You changed group name from \"Channel\" to \"Broadcast channel 42\"."
|
||||
"Channel name changed from \"Channel\" to \"Broadcast channel 42\"."
|
||||
);
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
|
||||
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
|
||||
@@ -4787,6 +4871,22 @@ async fn test_sync_create_group() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_contacts_are_hidden() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
send_text_msg(alice, alice_chat_id, "hello".to_string()).await?;
|
||||
bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
assert_eq!(Contact::get_all(alice, 0, None).await?.len(), 0);
|
||||
assert_eq!(Contact::get_all(bob, 0, None).await?.len(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests sending JPEG image with .png extension.
|
||||
///
|
||||
/// This is a regression test, previously sending failed
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::log::LogExt;
|
||||
use crate::mimefactory::RECOMMENDED_FILE_SIZE;
|
||||
use crate::provider::Provider;
|
||||
use crate::sync::{self, Sync::*, SyncData};
|
||||
use crate::tools::get_abs_path;
|
||||
use crate::tools::{get_abs_path, time};
|
||||
use crate::transport::{ConfiguredLoginParam, add_pseudo_transport, send_sync_transports};
|
||||
use crate::{constants, stats};
|
||||
|
||||
@@ -155,18 +155,6 @@ pub enum Config {
|
||||
#[strum(props(default = "1"))]
|
||||
MdnsEnabled,
|
||||
|
||||
/// True if chat messages should be moved to a separate folder. Auto-sent messages like sync
|
||||
/// ones are moved there anyway.
|
||||
#[strum(props(default = "1"))]
|
||||
MvboxMove,
|
||||
|
||||
/// Watch for new messages in the "Mvbox" (aka DeltaChat folder) only.
|
||||
///
|
||||
/// This will not entirely disable other folders, e.g. the spam folder will also still
|
||||
/// be watched for new messages.
|
||||
#[strum(props(default = "0"))]
|
||||
OnlyFetchMvbox,
|
||||
|
||||
/// Whether to show classic emails or only chat messages.
|
||||
#[strum(props(default = "2"))] // also change ShowEmails.default() on changes
|
||||
ShowEmails,
|
||||
@@ -268,9 +256,6 @@ pub enum Config {
|
||||
/// Configured folder for incoming messages.
|
||||
ConfiguredInboxFolder,
|
||||
|
||||
/// Configured folder for chat messages.
|
||||
ConfiguredMvboxFolder,
|
||||
|
||||
/// Unix timestamp of the last successful configuration.
|
||||
ConfiguredTimestamp,
|
||||
|
||||
@@ -467,7 +452,6 @@ impl Config {
|
||||
self,
|
||||
Self::Displayname
|
||||
| Self::MdnsEnabled
|
||||
| Self::MvboxMove
|
||||
| Self::ShowEmails
|
||||
| Self::Selfavatar
|
||||
| Self::Selfstatus,
|
||||
@@ -476,10 +460,7 @@ impl Config {
|
||||
|
||||
/// Whether the config option needs an IO scheduler restart to take effect.
|
||||
pub(crate) fn needs_io_restart(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ConfiguredAddr
|
||||
)
|
||||
matches!(self, Config::ConfiguredAddr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,13 +575,6 @@ impl Context {
|
||||
.is_some_and(|x| x != 0))
|
||||
}
|
||||
|
||||
/// Returns true if movebox ("DeltaChat" folder) should be watched.
|
||||
pub(crate) async fn should_watch_mvbox(&self) -> Result<bool> {
|
||||
Ok(self.get_config_bool(Config::MvboxMove).await?
|
||||
|| self.get_config_bool(Config::OnlyFetchMvbox).await?
|
||||
|| !self.get_config_bool(Config::IsChatmail).await?)
|
||||
}
|
||||
|
||||
/// Returns true if sync messages should be sent.
|
||||
pub(crate) async fn should_send_sync_msgs(&self) -> Result<bool> {
|
||||
Ok(self.get_config_bool(Config::SyncMsgs).await?
|
||||
@@ -682,8 +656,6 @@ impl Context {
|
||||
| Config::ProxyEnabled
|
||||
| Config::BccSelf
|
||||
| Config::MdnsEnabled
|
||||
| Config::MvboxMove
|
||||
| Config::OnlyFetchMvbox
|
||||
| Config::Configured
|
||||
| Config::Bot
|
||||
| Config::NotifyAboutWrongPw
|
||||
@@ -706,11 +678,6 @@ impl Context {
|
||||
pub async fn set_config(&self, key: Config, value: Option<&str>) -> Result<()> {
|
||||
Self::check_config(key, value)?;
|
||||
|
||||
let n_transports = self.count_transports().await?;
|
||||
if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) {
|
||||
bail!("Cannot reconfigure {key} when multiple transports are configured");
|
||||
}
|
||||
|
||||
let _pause = match key.needs_io_restart() {
|
||||
true => self.scheduler.pause(self).await?,
|
||||
_ => Default::default(),
|
||||
@@ -789,12 +756,6 @@ impl Context {
|
||||
.set_raw_config(key.as_ref(), value.map(|s| s.to_lowercase()).as_deref())
|
||||
.await?;
|
||||
}
|
||||
Config::MvboxMove => {
|
||||
self.sql.set_raw_config(key.as_ref(), value).await?;
|
||||
self.sql
|
||||
.set_raw_config(constants::DC_FOLDERS_CONFIGURED_KEY, None)
|
||||
.await?;
|
||||
}
|
||||
Config::ConfiguredAddr => {
|
||||
let Some(addr) = value else {
|
||||
bail!("Cannot unset configured_addr");
|
||||
@@ -828,6 +789,22 @@ impl Context {
|
||||
(addr,),
|
||||
)?;
|
||||
|
||||
// Update the timestamp for the primary transport
|
||||
// so it becomes the first in `get_all_self_addrs()` list
|
||||
// and the list of relays distributed in the public key.
|
||||
// This ensures that messages will be sent
|
||||
// to the primary relay by the contacts
|
||||
// and will be fetched in background_fetch()
|
||||
// which only fetches from the primary transport.
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE transports SET add_timestamp=? WHERE addr=?",
|
||||
(time(), addr),
|
||||
)
|
||||
.context(
|
||||
"Failed to update add_timestamp for the new primary transport",
|
||||
)?;
|
||||
|
||||
// Clean up SMTP and IMAP APPEND queue.
|
||||
//
|
||||
// The messages in the queue have a different
|
||||
@@ -944,12 +921,18 @@ impl Context {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the primary self address followed by all secondary ones.
|
||||
/// Returns all self addresses, newest first.
|
||||
pub(crate) async fn get_all_self_addrs(&self) -> Result<Vec<String>> {
|
||||
let primary_addrs = self.get_config(Config::ConfiguredAddr).await?.into_iter();
|
||||
let secondary_addrs = self.get_secondary_self_addrs().await?.into_iter();
|
||||
|
||||
Ok(primary_addrs.chain(secondary_addrs).collect())
|
||||
self.sql
|
||||
.query_map_vec(
|
||||
"SELECT addr FROM transports ORDER BY add_timestamp DESC",
|
||||
(),
|
||||
|row| {
|
||||
let addr: String = row.get(0)?;
|
||||
Ok(addr)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns all secondary self addresses.
|
||||
|
||||
@@ -196,11 +196,11 @@ async fn test_sync() -> Result<()> {
|
||||
sync(&alice0, &alice1).await;
|
||||
assert_eq!(alice1.get_config_bool(Config::MdnsEnabled).await?, false);
|
||||
|
||||
for key in [Config::ShowEmails, Config::MvboxMove] {
|
||||
let val = alice0.get_config_bool(key).await?;
|
||||
alice0.set_config_bool(key, !val).await?;
|
||||
{
|
||||
let val = alice0.get_config_bool(Config::ShowEmails).await?;
|
||||
alice0.set_config_bool(Config::ShowEmails, !val).await?;
|
||||
sync(&alice0, &alice1).await;
|
||||
assert_eq!(alice1.get_config_bool(key).await?, !val);
|
||||
assert_eq!(alice1.get_config_bool(Config::ShowEmails).await?, !val);
|
||||
}
|
||||
|
||||
// `Config::SyncMsgs` mustn't be synced.
|
||||
|
||||
@@ -273,31 +273,16 @@ impl Context {
|
||||
(¶m.addr,),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
// Should be checked before `MvboxMove` because the latter makes no sense in presense of
|
||||
// `OnlyFetchMvbox` and even grayed out in the UIs in this case.
|
||||
if self.get_config(Config::OnlyFetchMvbox).await?.as_deref() != Some("0") {
|
||||
bail!(
|
||||
"To use additional relays, disable the legacy option \"Settings / Advanced / Only Fetch from DeltaChat Folder\"."
|
||||
);
|
||||
}
|
||||
if self.get_config(Config::MvboxMove).await?.as_deref() != Some("0") {
|
||||
bail!(
|
||||
"To use additional relays, disable the legacy option \"Settings / Advanced / Move automatically to DeltaChat Folder\"."
|
||||
);
|
||||
}
|
||||
|
||||
if self
|
||||
&& self
|
||||
.sql
|
||||
.count("SELECT COUNT(*) FROM transports", ())
|
||||
.await?
|
||||
>= MAX_TRANSPORT_RELAYS
|
||||
{
|
||||
bail!(
|
||||
"You have reached the maximum number of relays ({}).",
|
||||
MAX_TRANSPORT_RELAYS
|
||||
)
|
||||
}
|
||||
{
|
||||
bail!(
|
||||
"You have reached the maximum number of relays ({}).",
|
||||
MAX_TRANSPORT_RELAYS
|
||||
)
|
||||
}
|
||||
|
||||
let provider = match configure(self, param).await {
|
||||
@@ -510,6 +495,7 @@ async fn get_configured_param(
|
||||
.collect(),
|
||||
imap_user: param.imap.user.clone(),
|
||||
imap_password: param.imap.password.clone(),
|
||||
imap_folder: Some(param.imap.folder.clone()).filter(|folder| !folder.is_empty()),
|
||||
smtp: servers
|
||||
.iter()
|
||||
.filter_map(|params| {
|
||||
@@ -549,9 +535,6 @@ async fn get_configured_param(
|
||||
async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'static Provider>> {
|
||||
progress!(ctx, 1);
|
||||
|
||||
let ctx2 = ctx.clone();
|
||||
let update_device_chats_handle = task::spawn(async move { ctx2.update_device_chats().await });
|
||||
|
||||
let configured_param = get_configured_param(ctx, param).await?;
|
||||
let proxy_config = ProxyConfig::load(ctx).await?;
|
||||
let strict_tls = configured_param.strict_tls(proxy_config.is_some());
|
||||
@@ -590,11 +573,14 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
let (_s, r) = async_channel::bounded(1);
|
||||
let mut imap = Imap::new(ctx, transport_id, configured_param.clone(), r).await?;
|
||||
let configuring = true;
|
||||
if let Err(err) = imap.connect(ctx, configuring).await {
|
||||
bail!(
|
||||
"{}",
|
||||
nicer_configuration_error(ctx, format!("{err:#}")).await
|
||||
);
|
||||
let imap_session = match imap.connect(ctx, configuring).await {
|
||||
Ok(imap_session) => imap_session,
|
||||
Err(err) => {
|
||||
bail!(
|
||||
"{}",
|
||||
nicer_configuration_error(ctx, format!("{err:#}")).await
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
progress!(ctx, 850);
|
||||
@@ -605,11 +591,17 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
progress!(ctx, 900);
|
||||
|
||||
let is_configured = ctx.is_configured().await?;
|
||||
if !is_configured {
|
||||
ctx.sql.set_raw_config("mvbox_move", Some("0")).await?;
|
||||
ctx.sql.set_raw_config("only_fetch_mvbox", None).await?;
|
||||
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
|
||||
if imap_session.is_chatmail() {
|
||||
ctx.sql.set_raw_config("is_chatmail", Some("1")).await?;
|
||||
} else if !is_configured {
|
||||
// Reset the setting that may have been set
|
||||
// during failed configuration.
|
||||
ctx.sql.set_raw_config("is_chatmail", Some("0")).await?;
|
||||
}
|
||||
}
|
||||
|
||||
drop(imap_session);
|
||||
drop(imap);
|
||||
|
||||
progress!(ctx, 910);
|
||||
@@ -629,7 +621,9 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
ctx.scheduler.interrupt_inbox().await;
|
||||
|
||||
progress!(ctx, 940);
|
||||
update_device_chats_handle.await??;
|
||||
ctx.update_device_chats()
|
||||
.await
|
||||
.context("Failed to update device chats")?;
|
||||
|
||||
ctx.sql.set_raw_config_bool("configured", true).await?;
|
||||
ctx.emit_event(EventType::AccountsItemChanged);
|
||||
@@ -760,7 +754,7 @@ pub enum Error {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::login_param::EnteredServerLoginParam;
|
||||
use crate::login_param::EnteredImapLoginParam;
|
||||
use crate::test_utils::TestContext;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -779,7 +773,7 @@ mod tests {
|
||||
let entered_param = EnteredLoginParam {
|
||||
addr: "alice@example.org".to_string(),
|
||||
|
||||
imap: EnteredServerLoginParam {
|
||||
imap: EnteredImapLoginParam {
|
||||
user: "alice@example.net".to_string(),
|
||||
password: "foobar".to_string(),
|
||||
..Default::default()
|
||||
|
||||
@@ -210,11 +210,6 @@ pub const WORSE_IMAGE_SIZE: u32 = 640;
|
||||
/// usage by UIs.
|
||||
pub const MAX_RCVD_IMAGE_PIXELS: u32 = 50_000_000;
|
||||
|
||||
// Key for the folder configuration version (see below).
|
||||
pub(crate) const DC_FOLDERS_CONFIGURED_KEY: &str = "folders_configured";
|
||||
// this value can be increased if the folder configuration is changed and must be redone on next program start
|
||||
pub(crate) const DC_FOLDERS_CONFIGURED_VERSION: i32 = 5;
|
||||
|
||||
// If more recipients are needed in SMTP's `RCPT TO:` header, the recipient list is split into
|
||||
// chunks. This does not affect MIME's `To:` header. Can be overwritten by setting
|
||||
// `max_smtp_rcpt_to` in the provider db.
|
||||
|
||||
@@ -35,6 +35,7 @@ use crate::log::{LogExt, warn};
|
||||
use crate::message::MessageState;
|
||||
use crate::mimeparser::AvatarAction;
|
||||
use crate::param::{Param, Params};
|
||||
use crate::pgp::{addresses_from_public_key, merge_openpgp_certificates};
|
||||
use crate::sync::{self, Sync::*};
|
||||
use crate::tools::{SystemTime, duration_to_str, get_abs_path, normalize_text, time, to_lowercase};
|
||||
use crate::{chat, chatlist_events, ensure_and_debug_assert_ne, stock_str};
|
||||
@@ -314,6 +315,67 @@ pub async fn make_vcard(context: &Context, contacts: &[ContactId]) -> Result<Str
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Imports public key into the public key store.
|
||||
///
|
||||
/// They key may come from Autocrypt header,
|
||||
/// Autocrypt-Gossip header or a vCard.
|
||||
///
|
||||
/// If the key with the same fingerprint already exists,
|
||||
/// it is updated by merging the new key.
|
||||
pub(crate) async fn import_public_key(
|
||||
context: &Context,
|
||||
public_key: &SignedPublicKey,
|
||||
) -> Result<()> {
|
||||
public_key
|
||||
.verify_bindings()
|
||||
.context("Attempt to import broken public key")?;
|
||||
|
||||
let fingerprint = public_key.dc_fingerprint().hex();
|
||||
|
||||
let merged_public_key;
|
||||
let merged_public_key_ref = if let Some(public_key_bytes) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT public_key
|
||||
FROM public_keys
|
||||
WHERE fingerprint=?",
|
||||
(&fingerprint,),
|
||||
|row| {
|
||||
let bytes: Vec<u8> = row.get(0)?;
|
||||
Ok(bytes)
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let old_public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
|
||||
merged_public_key = merge_openpgp_certificates(public_key.clone(), old_public_key)
|
||||
.context("Failed to merge public keys")?;
|
||||
&merged_public_key
|
||||
} else {
|
||||
public_key
|
||||
};
|
||||
|
||||
let inserted = context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO public_keys (fingerprint, public_key)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (fingerprint)
|
||||
DO UPDATE SET public_key=excluded.public_key
|
||||
WHERE public_key!=excluded.public_key",
|
||||
(&fingerprint, merged_public_key_ref.to_bytes()),
|
||||
)
|
||||
.await?;
|
||||
if inserted > 0 {
|
||||
info!(
|
||||
context,
|
||||
"Saved key with fingerprint {fingerprint} from the Autocrypt header"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Imports contacts from the given vCard.
|
||||
///
|
||||
/// Returns the ids of successfully processed contacts in the order they appear in `vcard`,
|
||||
@@ -352,23 +414,14 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
|
||||
.ok()
|
||||
});
|
||||
|
||||
let fingerprint;
|
||||
if let Some(public_key) = key {
|
||||
fingerprint = public_key.dc_fingerprint().hex();
|
||||
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO public_keys (fingerprint, public_key)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (fingerprint)
|
||||
DO NOTHING",
|
||||
(&fingerprint, public_key.to_bytes()),
|
||||
)
|
||||
.await?;
|
||||
let fingerprint = if let Some(public_key) = key {
|
||||
import_public_key(context, &public_key)
|
||||
.await
|
||||
.context("Failed to import public key from vCard")?;
|
||||
public_key.dc_fingerprint().hex()
|
||||
} else {
|
||||
fingerprint = String::new();
|
||||
}
|
||||
String::new()
|
||||
};
|
||||
|
||||
let (id, modified) =
|
||||
match Contact::add_or_lookup_ex(context, &contact.authname, &addr, &fingerprint, origin)
|
||||
@@ -1344,7 +1397,7 @@ WHERE addr=?
|
||||
let fingerprint_other = fingerprint_other.to_string();
|
||||
|
||||
let stock_message = if contact.public_key(context).await?.is_some() {
|
||||
stock_str::messages_e2e_encrypted(context).await
|
||||
stock_str::messages_are_e2ee(context).await
|
||||
} else {
|
||||
stock_str::encr_none(context).await
|
||||
};
|
||||
@@ -1384,6 +1437,16 @@ WHERE addr=?
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(public_key) = contact.public_key(context).await?
|
||||
&& let Some(relay_addrs) = addresses_from_public_key(&public_key)
|
||||
{
|
||||
ret += "\n\nRelays:";
|
||||
for relay in &relay_addrs {
|
||||
ret += "\n";
|
||||
ret += relay;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
|
||||
@@ -841,7 +841,10 @@ Me (alice@example.org):
|
||||
|
||||
bob@example.net (bob@example.net):
|
||||
CCCB 5AA9 F6E1 141C 9431
|
||||
65F1 DB18 B18C BCF7 0487"
|
||||
65F1 DB18 B18C BCF7 0487
|
||||
|
||||
Relays:
|
||||
bob@example.net"
|
||||
);
|
||||
let contact = Contact::get_by_id(alice, contact_bob_id).await?;
|
||||
assert!(contact.e2ee_avail(alice).await?);
|
||||
@@ -1145,8 +1148,11 @@ async fn test_make_n_import_vcard() -> Result<()> {
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
bob.set_config(Config::Displayname, Some("Bob")).await?;
|
||||
bob.set_config(Config::Selfstatus, Some("It's me, bob"))
|
||||
.await?;
|
||||
bob.set_config(
|
||||
Config::Selfstatus,
|
||||
Some("It's me,\nbob; and here's a backslash: \\"),
|
||||
)
|
||||
.await?;
|
||||
let avatar_path = bob.dir.path().join("avatar.png");
|
||||
let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png");
|
||||
let avatar_base64 = base64::engine::general_purpose::STANDARD.encode(avatar_bytes);
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use async_channel::{self as channel, Receiver, Sender};
|
||||
use pgp::composed::SignedPublicKey;
|
||||
use ratelimit::Ratelimit;
|
||||
use tokio::sync::{Mutex, Notify, RwLock};
|
||||
|
||||
@@ -19,7 +20,7 @@ use crate::constants::{self, DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT, DC_VERSI
|
||||
use crate::contact::{Contact, ContactId};
|
||||
use crate::debug_logging::DebugLogging;
|
||||
use crate::events::{Event, EventEmitter, EventType, Events};
|
||||
use crate::imap::{FolderMeaning, Imap, ServerMetadata};
|
||||
use crate::imap::{Imap, ServerMetadata};
|
||||
use crate::key::self_fingerprint;
|
||||
use crate::log::warn;
|
||||
use crate::logged_debug_assert;
|
||||
@@ -28,7 +29,7 @@ use crate::net::tls::TlsSessionStore;
|
||||
use crate::peer_channels::Iroh;
|
||||
use crate::push::PushSubscriber;
|
||||
use crate::quota::QuotaInfo;
|
||||
use crate::scheduler::{ConnectivityStore, SchedulerState, convert_folder_meaning};
|
||||
use crate::scheduler::{ConnectivityStore, SchedulerState};
|
||||
use crate::sql::Sql;
|
||||
use crate::stock_str::StockStrings;
|
||||
use crate::timesmearing::SmearedTimestamp;
|
||||
@@ -233,12 +234,21 @@ pub struct InnerContext {
|
||||
/// This is a global mutex-like state for operations which should be modal in the
|
||||
/// clients.
|
||||
running_state: RwLock<RunningState>,
|
||||
/// Mutex to avoid generating the key for the user more than once.
|
||||
pub(crate) generating_key_mutex: Mutex<()>,
|
||||
/// Mutex to enforce only a single running oauth2 is running.
|
||||
pub(crate) oauth2_mutex: Mutex<()>,
|
||||
/// Mutex to prevent a race condition when a "your pw is wrong" warning is sent, resulting in multiple messages being sent.
|
||||
pub(crate) wrong_pw_warning_mutex: Mutex<()>,
|
||||
/// Mutex to prevent running housekeeping from multiple threads at once.
|
||||
pub(crate) housekeeping_mutex: Mutex<()>,
|
||||
|
||||
/// Mutex to prevent multiple IMAP loops from fetching the messages at once.
|
||||
///
|
||||
/// Without this mutex IMAP loops may waste traffic downloading the same message
|
||||
/// from multiple IMAP servers and create multiple copies of the same message
|
||||
/// in the database if the check for duplicates and creating a message
|
||||
/// happens in separate database transactions.
|
||||
pub(crate) fetch_msgs_mutex: Mutex<()>,
|
||||
|
||||
pub(crate) translated_stockstrings: StockStrings,
|
||||
pub(crate) events: Events,
|
||||
|
||||
@@ -306,6 +316,13 @@ pub struct InnerContext {
|
||||
/// the standard library's OnceLock is enough, and it's a lot smaller in memory.
|
||||
pub(crate) self_fingerprint: OnceLock<String>,
|
||||
|
||||
/// OpenPGP certificate aka Transferrable Public Key.
|
||||
///
|
||||
/// It is generated on first use from the secret key stored in the database.
|
||||
///
|
||||
/// Mutex is also held while generating the key to avoid generating the key twice.
|
||||
pub(crate) self_public_key: Mutex<Option<SignedPublicKey>>,
|
||||
|
||||
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
|
||||
/// see [`Context::get_connectivity()`].
|
||||
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
|
||||
@@ -475,9 +492,10 @@ impl Context {
|
||||
running_state: RwLock::new(Default::default()),
|
||||
sql: Sql::new(dbfile),
|
||||
smeared_timestamp: SmearedTimestamp::new(),
|
||||
generating_key_mutex: Mutex::new(()),
|
||||
oauth2_mutex: Mutex::new(()),
|
||||
wrong_pw_warning_mutex: Mutex::new(()),
|
||||
housekeeping_mutex: Mutex::new(()),
|
||||
fetch_msgs_mutex: Mutex::new(()),
|
||||
translated_stockstrings: stockstrings,
|
||||
events,
|
||||
scheduler: SchedulerState::new(),
|
||||
@@ -495,6 +513,7 @@ impl Context {
|
||||
tls_session_store: TlsSessionStore::new(),
|
||||
iroh: Arc::new(RwLock::new(None)),
|
||||
self_fingerprint: OnceLock::new(),
|
||||
self_public_key: Mutex::new(None),
|
||||
connectivities: parking_lot::Mutex::new(Vec::new()),
|
||||
pre_encrypt_mime_hook: None.into(),
|
||||
};
|
||||
@@ -604,17 +623,10 @@ impl Context {
|
||||
let mut session = connection.prepare(self).await?;
|
||||
|
||||
// Fetch IMAP folders.
|
||||
// Inbox is fetched before Mvbox because fetching from Inbox
|
||||
// may result in moving some messages to Mvbox.
|
||||
for folder_meaning in [FolderMeaning::Inbox, FolderMeaning::Mvbox] {
|
||||
if let Some((_folder_config, watch_folder)) =
|
||||
convert_folder_meaning(self, folder_meaning).await?
|
||||
{
|
||||
connection
|
||||
.fetch_move_delete(self, &mut session, &watch_folder, folder_meaning)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let folder = connection.folder.clone();
|
||||
connection
|
||||
.fetch_move_delete(self, &mut session, &folder)
|
||||
.await?;
|
||||
|
||||
// Update quota (to send warning if full) - but only check it once in a while.
|
||||
// note: For now this only checks quota of primary transport,
|
||||
@@ -625,7 +637,7 @@ impl Context {
|
||||
DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT,
|
||||
)
|
||||
.await
|
||||
&& let Err(err) = self.update_recent_quota(&mut session).await
|
||||
&& let Err(err) = self.update_recent_quota(&mut session, folder).await
|
||||
{
|
||||
warn!(self, "Failed to update quota: {err:#}.");
|
||||
}
|
||||
@@ -865,23 +877,6 @@ impl Context {
|
||||
Err(err) => format!("<key failure: {err}>"),
|
||||
};
|
||||
|
||||
let mvbox_move = self.get_config_int(Config::MvboxMove).await?;
|
||||
let only_fetch_mvbox = self.get_config_int(Config::OnlyFetchMvbox).await?;
|
||||
let folders_configured = self
|
||||
.sql
|
||||
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
let configured_inbox_folder = self
|
||||
.get_config(Config::ConfiguredInboxFolder)
|
||||
.await?
|
||||
.unwrap_or_else(|| "<unset>".to_string());
|
||||
let configured_mvbox_folder = self
|
||||
.get_config(Config::ConfiguredMvboxFolder)
|
||||
.await?
|
||||
.unwrap_or_else(|| "<unset>".to_string());
|
||||
|
||||
let mut res = get_info();
|
||||
|
||||
// insert values
|
||||
@@ -957,14 +952,6 @@ impl Context {
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert("mvbox_move", mvbox_move.to_string());
|
||||
res.insert("only_fetch_mvbox", only_fetch_mvbox.to_string());
|
||||
res.insert(
|
||||
constants::DC_FOLDERS_CONFIGURED_KEY,
|
||||
folders_configured.to_string(),
|
||||
);
|
||||
res.insert("configured_inbox_folder", configured_inbox_folder);
|
||||
res.insert("configured_mvbox_folder", configured_mvbox_folder);
|
||||
res.insert("mdns_enabled", mdns_enabled.to_string());
|
||||
res.insert("bcc_self", bcc_self.to_string());
|
||||
res.insert("sync_msgs", sync_msgs.to_string());
|
||||
@@ -1264,12 +1251,6 @@ ORDER BY m.timestamp DESC,m.id DESC",
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Returns true if given folder name is the name of the "DeltaChat" folder.
|
||||
pub async fn is_mvbox(&self, folder_name: &str) -> Result<bool> {
|
||||
let mvbox = self.get_config(Config::ConfiguredMvboxFolder).await?;
|
||||
Ok(mvbox.as_deref() == Some(folder_name))
|
||||
}
|
||||
|
||||
pub(crate) fn derive_blobdir(dbfile: &Path) -> PathBuf {
|
||||
let mut blob_fname = OsString::new();
|
||||
blob_fname.push(dbfile.file_name().unwrap_or_default());
|
||||
|
||||
260
src/decrypt.rs
260
src/decrypt.rs
@@ -1,34 +1,256 @@
|
||||
//! End-to-end decryption support.
|
||||
//! Helper functions for decryption.
|
||||
//! The actual decryption is done in the [`crate::pgp`] module.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::Cursor;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use mailparse::ParsedMail;
|
||||
use pgp::composed::Esk;
|
||||
use pgp::composed::Message;
|
||||
use pgp::composed::PlainSessionKey;
|
||||
use pgp::composed::SignedSecretKey;
|
||||
use pgp::composed::decrypt_session_key_with_password;
|
||||
use pgp::packet::SymKeyEncryptedSessionKey;
|
||||
use pgp::types::Password;
|
||||
use pgp::types::StringToKey;
|
||||
|
||||
use crate::key::{Fingerprint, SignedPublicKey, SignedSecretKey};
|
||||
use crate::pgp;
|
||||
use crate::chat::ChatId;
|
||||
use crate::constants::Chattype;
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::key::self_fingerprint;
|
||||
use crate::key::{Fingerprint, SignedPublicKey, load_self_secret_keyring};
|
||||
use crate::token::Namespace;
|
||||
|
||||
/// Tries to decrypt a message, but only if it is structured as an Autocrypt message.
|
||||
/// Tries to decrypt the message,
|
||||
/// returning a tuple of `(decrypted message, fingerprint)`.
|
||||
///
|
||||
/// If successful and the message was encrypted,
|
||||
/// returns the decrypted and decompressed message.
|
||||
pub fn try_decrypt<'a>(
|
||||
/// If the message wasn't encrypted, returns `Ok(None)`.
|
||||
///
|
||||
/// If the message was asymmetrically encrypted, returns `Ok((decrypted message, None))`.
|
||||
///
|
||||
/// If the message was symmetrically encrypted, returns `Ok((decrypted message, Some(fingerprint)))`,
|
||||
/// where `fingerprint` denotes which contact is allowed to send encrypted with this symmetric secret.
|
||||
/// If the message is not signed by `fingerprint`, it must be dropped.
|
||||
///
|
||||
/// Otherwise, Eve could send a message to Alice
|
||||
/// encrypted with the symmetric secret of someone else's broadcast channel.
|
||||
/// If Alice sends an answer (or read receipt),
|
||||
/// then Eve would know that Alice is in the broadcast channel.
|
||||
pub(crate) async fn decrypt(
|
||||
context: &Context,
|
||||
mail: &mailparse::ParsedMail<'_>,
|
||||
) -> Result<Option<(Message<'static>, Option<String>)>> {
|
||||
// `pgp::composed::Message` is huge (>4kb), so, make sure that it is in a Box when held over an await point
|
||||
let Some(msg) = get_encrypted_pgp_message_boxed(mail)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let expected_sender_fingerprint: Option<String>;
|
||||
|
||||
let plain = if let Message::Encrypted { esk, .. } = &*msg
|
||||
// We only allow one ESK for symmetrically encrypted messages
|
||||
// to avoid dealing with messages that are encrypted to multiple symmetric keys
|
||||
// or a mix of symmetric and asymmetric keys:
|
||||
&& let [Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..]
|
||||
{
|
||||
check_symmetric_encryption(esk)?;
|
||||
let (psk, fingerprint) = decrypt_session_key_symmetrically(context, esk)
|
||||
.await
|
||||
.context("decrypt_session_key_symmetrically")?;
|
||||
expected_sender_fingerprint = fingerprint;
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
|
||||
let plain = msg
|
||||
.decrypt_with_session_key(psk)
|
||||
.context("decrypt_with_session_key")?;
|
||||
|
||||
let plain: Message<'static> = plain.decompress()?;
|
||||
Ok(plain)
|
||||
})
|
||||
.await??
|
||||
} else {
|
||||
// Message is asymmetrically encrypted
|
||||
let secret_keys: Vec<SignedSecretKey> = load_self_secret_keyring(context).await?;
|
||||
expected_sender_fingerprint = None;
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
|
||||
let empty_pw = Password::empty();
|
||||
let secret_keys: Vec<&SignedSecretKey> = secret_keys.iter().collect();
|
||||
let plain = msg
|
||||
.decrypt_with_keys(vec![&empty_pw], secret_keys)
|
||||
.context("decrypt_with_keys")?;
|
||||
|
||||
let plain: Message<'static> = plain.decompress()?;
|
||||
Ok(plain)
|
||||
})
|
||||
.await??
|
||||
};
|
||||
|
||||
Ok(Some((plain, expected_sender_fingerprint)))
|
||||
}
|
||||
|
||||
async fn decrypt_session_key_symmetrically(
|
||||
context: &Context,
|
||||
esk: &SymKeyEncryptedSessionKey,
|
||||
) -> Result<(PlainSessionKey, Option<String>)> {
|
||||
let self_fp = self_fingerprint(context).await?;
|
||||
let query_only = true;
|
||||
context
|
||||
.sql
|
||||
.call(query_only, |conn| {
|
||||
// First, try decrypting using AUTH tokens from scanned QR codes, stored in the bobstate,
|
||||
// because usually there will only be 1 or 2 of it, so, it should be fast
|
||||
let res: Option<(PlainSessionKey, String)> = try_decrypt_with_bobstate(esk, conn)?;
|
||||
if let Some((plain_session_key, fingerprint)) = res {
|
||||
return Ok((plain_session_key, Some(fingerprint)));
|
||||
}
|
||||
|
||||
// Then, try decrypting using broadcast secrets
|
||||
let res: Option<(PlainSessionKey, Option<String>)> =
|
||||
try_decrypt_with_broadcast_secret(esk, conn)?;
|
||||
if let Some((plain_session_key, fingerprint)) = res {
|
||||
return Ok((plain_session_key, fingerprint));
|
||||
}
|
||||
|
||||
// Finally, try decrypting using own AUTH tokens
|
||||
// There can be a lot of AUTH tokens,
|
||||
// because a new one is generated every time a QR code is shown
|
||||
let res: Option<PlainSessionKey> = try_decrypt_with_auth_token(esk, conn, self_fp)?;
|
||||
if let Some(plain_session_key) = res {
|
||||
return Ok((plain_session_key, None));
|
||||
}
|
||||
|
||||
bail!("Could not find symmetric secret for session key")
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn try_decrypt_with_bobstate(
|
||||
esk: &SymKeyEncryptedSessionKey,
|
||||
conn: &mut rusqlite::Connection,
|
||||
) -> Result<Option<(PlainSessionKey, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT invite FROM bobstate")?;
|
||||
let mut rows = stmt.query(())?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let invite: crate::securejoin::QrInvite = row.get(0)?;
|
||||
let authcode = invite.authcode().to_string();
|
||||
let alice_fp = invite.fingerprint().hex();
|
||||
let shared_secret = format!("securejoin/{alice_fp}/{authcode}");
|
||||
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
|
||||
let fingerprint = invite.fingerprint().hex();
|
||||
return Ok(Some((psk, fingerprint)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn try_decrypt_with_broadcast_secret(
|
||||
esk: &SymKeyEncryptedSessionKey,
|
||||
conn: &mut rusqlite::Connection,
|
||||
) -> Result<Option<(PlainSessionKey, Option<String>)>> {
|
||||
let Some((psk, chat_id)) = try_decrypt_with_broadcast_secret_inner(esk, conn)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let chat_type: Chattype =
|
||||
conn.query_one("SELECT type FROM chats WHERE id=?", (chat_id,), |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let fp: Option<String> = if chat_type == Chattype::OutBroadcast {
|
||||
// An attacker who knows the secret will also know who owns it,
|
||||
// and it's easiest code-wise to just return None here.
|
||||
// But we could alternatively return the self fingerprint here
|
||||
None
|
||||
} else if chat_type == Chattype::InBroadcast {
|
||||
let contact_id: ContactId = conn
|
||||
.query_one(
|
||||
"SELECT contact_id FROM chats_contacts WHERE chat_id=? AND contact_id>9",
|
||||
(chat_id,),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context("Find InBroadcast owner")?;
|
||||
let fp = conn
|
||||
.query_one(
|
||||
"SELECT fingerprint FROM contacts WHERE id=?",
|
||||
(contact_id,),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context("Find owner fingerprint")?;
|
||||
Some(fp)
|
||||
} else {
|
||||
bail!("Chat {chat_id} is not a broadcast but {chat_type}")
|
||||
};
|
||||
Ok(Some((psk, fp)))
|
||||
}
|
||||
|
||||
fn try_decrypt_with_broadcast_secret_inner(
|
||||
esk: &SymKeyEncryptedSessionKey,
|
||||
conn: &mut rusqlite::Connection,
|
||||
) -> Result<Option<(PlainSessionKey, ChatId)>> {
|
||||
let mut stmt = conn.prepare("SELECT secret, chat_id FROM broadcast_secrets")?;
|
||||
let mut rows = stmt.query(())?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let secret: String = row.get(0)?;
|
||||
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(secret)) {
|
||||
let chat_id: ChatId = row.get(1)?;
|
||||
return Ok(Some((psk, chat_id)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn try_decrypt_with_auth_token(
|
||||
esk: &SymKeyEncryptedSessionKey,
|
||||
conn: &mut rusqlite::Connection,
|
||||
self_fingerprint: &str,
|
||||
) -> Result<Option<PlainSessionKey>> {
|
||||
// ORDER BY id DESC to query the most-recently saved tokens are returned first.
|
||||
// This improves performance when Bob scans a QR code that was just created.
|
||||
let mut stmt = conn.prepare("SELECT token FROM tokens WHERE namespc=? ORDER BY id DESC")?;
|
||||
let mut rows = stmt.query((Namespace::Auth,))?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let token: String = row.get(0)?;
|
||||
let shared_secret = format!("securejoin/{self_fingerprint}/{token}");
|
||||
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
|
||||
return Ok(Some(psk));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
|
||||
/// and Err with a reason if symmetric decryption should not be tried.
|
||||
///
|
||||
/// A DoS attacker could send a message with a lot of encrypted session keys,
|
||||
/// all of which use a very hard-to-compute string2key algorithm.
|
||||
/// We would then try to decrypt all of the encrypted session keys
|
||||
/// with all of the known shared secrets.
|
||||
/// In order to prevent this, we do not try to symmetrically decrypt messages
|
||||
/// that use a string2key algorithm other than 'Salted'.
|
||||
pub(crate) fn check_symmetric_encryption(esk: &SymKeyEncryptedSessionKey) -> Result<()> {
|
||||
match esk.s2k() {
|
||||
Some(StringToKey::Salted { .. }) => Ok(()),
|
||||
_ => bail!("unsupported string2key algorithm"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a [`ParsedMail`] into [`pgp::composed::Message`].
|
||||
/// [`pgp::composed::Message`] is huge (over 4kb),
|
||||
/// so, it is put on the heap using [`Box`].
|
||||
pub fn get_encrypted_pgp_message_boxed<'a>(
|
||||
mail: &'a ParsedMail<'a>,
|
||||
private_keyring: &'a [SignedSecretKey],
|
||||
shared_secrets: &[String],
|
||||
) -> Result<Option<::pgp::composed::Message<'static>>> {
|
||||
) -> Result<Option<Box<Message<'static>>>> {
|
||||
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let data = encrypted_data_part.get_body_raw()?;
|
||||
let msg = pgp::decrypt(data, private_keyring, shared_secrets)?;
|
||||
|
||||
Ok(Some(msg))
|
||||
let cursor = Cursor::new(data);
|
||||
let (msg, _headers) = Message::from_armor(cursor)?;
|
||||
Ok(Some(Box::new(msg)))
|
||||
}
|
||||
|
||||
/// Returns a reference to the encrypted payload of a message.
|
||||
pub(crate) fn get_encrypted_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
|
||||
pub fn get_encrypted_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
|
||||
get_autocrypt_mime(mail)
|
||||
.or_else(|| get_mixed_up_mime(mail))
|
||||
.or_else(|| get_attachment_mime(mail))
|
||||
@@ -131,8 +353,10 @@ pub(crate) fn validate_detached_signature<'a, 'b>(
|
||||
// First part is the content, second part is the signature.
|
||||
let content = first_part.raw_bytes;
|
||||
let ret_valid_signatures = match second_part.get_body_raw() {
|
||||
Ok(signature) => pgp::pk_validate(content, &signature, public_keyring_for_validate)
|
||||
.unwrap_or_default(),
|
||||
Ok(signature) => {
|
||||
crate::pgp::pk_validate(content, &signature, public_keyring_for_validate)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
Err(_) => Default::default(),
|
||||
};
|
||||
Some((first_part, ret_valid_signatures))
|
||||
|
||||
@@ -70,8 +70,13 @@ impl EncryptHelper {
|
||||
shared_secret: &str,
|
||||
mail_to_encrypt: MimePart<'static>,
|
||||
compress: bool,
|
||||
sign: bool,
|
||||
) -> Result<String> {
|
||||
let sign_key = load_self_secret_key(context).await?;
|
||||
let sign_key = if sign {
|
||||
Some(load_self_secret_key(context).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut raw_message = Vec::new();
|
||||
let cursor = Cursor::new(&mut raw_message);
|
||||
|
||||
@@ -397,6 +397,8 @@ pub enum EventType {
|
||||
msg_id: MsgId,
|
||||
/// ID of the chat which the message belongs to.
|
||||
chat_id: ChatId,
|
||||
/// The call was accepted from this device (process).
|
||||
from_this_device: bool,
|
||||
},
|
||||
|
||||
/// Outgoing call accepted.
|
||||
|
||||
306
src/imap.rs
306
src/imap.rs
@@ -18,7 +18,6 @@ use async_channel::{self, Receiver, Sender};
|
||||
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
|
||||
use futures::{FutureExt as _, TryStreamExt};
|
||||
use futures_lite::FutureExt;
|
||||
use num_traits::FromPrimitive;
|
||||
use ratelimit::Ratelimit;
|
||||
use url::Url;
|
||||
|
||||
@@ -28,13 +27,14 @@ use crate::calls::{
|
||||
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
|
||||
use crate::chatlist_events;
|
||||
use crate::config::Config;
|
||||
use crate::constants::{self, Blocked, DC_VERSION_STR, ShowEmails};
|
||||
use crate::constants::{Blocked, DC_VERSION_STR};
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::ensure_and_debug_assert;
|
||||
use crate::events::EventType;
|
||||
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::message::{self, Message, MessageState, MessengerMessage, MsgId};
|
||||
use crate::message::{self, Message, MessageState, MsgId};
|
||||
use crate::mimeparser;
|
||||
use crate::net::proxy::ProxyConfig;
|
||||
use crate::net::session::SessionStream;
|
||||
@@ -92,6 +92,9 @@ pub(crate) struct Imap {
|
||||
|
||||
oauth2: bool,
|
||||
|
||||
/// Watched folder.
|
||||
pub(crate) folder: String,
|
||||
|
||||
authentication_failed_once: bool,
|
||||
|
||||
pub(crate) connectivity: ConnectivityStore,
|
||||
@@ -163,7 +166,6 @@ pub enum FolderMeaning {
|
||||
/// Spam folder.
|
||||
Spam,
|
||||
Inbox,
|
||||
Mvbox,
|
||||
Trash,
|
||||
|
||||
/// Virtual folders.
|
||||
@@ -175,19 +177,6 @@ pub enum FolderMeaning {
|
||||
Virtual,
|
||||
}
|
||||
|
||||
impl FolderMeaning {
|
||||
pub fn to_config(self) -> Option<Config> {
|
||||
match self {
|
||||
FolderMeaning::Unknown => None,
|
||||
FolderMeaning::Spam => None,
|
||||
FolderMeaning::Inbox => Some(Config::ConfiguredInboxFolder),
|
||||
FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder),
|
||||
FolderMeaning::Trash => None,
|
||||
FolderMeaning::Virtual => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UidGrouper<T: Iterator<Item = (i64, u32, String)>> {
|
||||
inner: Peekable<T>,
|
||||
}
|
||||
@@ -264,6 +253,11 @@ impl Imap {
|
||||
let addr = ¶m.addr;
|
||||
let strict_tls = param.strict_tls(proxy_config.is_some());
|
||||
let oauth2 = param.oauth2;
|
||||
let folder = param
|
||||
.imap_folder
|
||||
.clone()
|
||||
.unwrap_or_else(|| "INBOX".to_string());
|
||||
ensure_and_debug_assert!(!folder.is_empty(), "Watched folder name cannot be empty");
|
||||
let (resync_request_sender, resync_request_receiver) = async_channel::bounded(1);
|
||||
Ok(Imap {
|
||||
transport_id,
|
||||
@@ -274,6 +268,7 @@ impl Imap {
|
||||
proxy_config,
|
||||
strict_tls,
|
||||
oauth2,
|
||||
folder,
|
||||
authentication_failed_once: false,
|
||||
connectivity: Default::default(),
|
||||
conn_last_try: UNIX_EPOCH,
|
||||
@@ -486,7 +481,7 @@ impl Imap {
|
||||
/// that folders are created and IMAP capabilities are determined.
|
||||
pub(crate) async fn prepare(&mut self, context: &Context) -> Result<Session> {
|
||||
let configuring = false;
|
||||
let mut session = match self.connect(context, configuring).await {
|
||||
let session = match self.connect(context, configuring).await {
|
||||
Ok(session) => session,
|
||||
Err(err) => {
|
||||
self.connectivity.set_err(context, &err);
|
||||
@@ -494,14 +489,6 @@ impl Imap {
|
||||
}
|
||||
};
|
||||
|
||||
let folders_configured = context
|
||||
.sql
|
||||
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
|
||||
.await?;
|
||||
if folders_configured.unwrap_or_default() < constants::DC_FOLDERS_CONFIGURED_VERSION {
|
||||
self.configure_folders(context, &mut session).await?;
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@@ -514,15 +501,15 @@ impl Imap {
|
||||
context: &Context,
|
||||
session: &mut Session,
|
||||
watch_folder: &str,
|
||||
folder_meaning: FolderMeaning,
|
||||
) -> Result<()> {
|
||||
ensure_and_debug_assert!(!watch_folder.is_empty(), "Watched folder cannot be empty");
|
||||
if !context.sql.is_open().await {
|
||||
// probably shutdown
|
||||
bail!("IMAP operation attempted while it is torn down");
|
||||
}
|
||||
|
||||
let msgs_fetched = self
|
||||
.fetch_new_messages(context, session, watch_folder, folder_meaning)
|
||||
.fetch_new_messages(context, session, watch_folder)
|
||||
.await
|
||||
.context("fetch_new_messages")?;
|
||||
if msgs_fetched && context.get_config_delete_device_after().await?.is_some() {
|
||||
@@ -550,14 +537,7 @@ impl Imap {
|
||||
context: &Context,
|
||||
session: &mut Session,
|
||||
folder: &str,
|
||||
folder_meaning: FolderMeaning,
|
||||
) -> Result<bool> {
|
||||
if should_ignore_folder(context, folder, folder_meaning).await? {
|
||||
info!(context, "Not fetching from {folder:?}.");
|
||||
session.new_mail = false;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let folder_exists = session
|
||||
.select_with_uidvalidity(context, folder)
|
||||
.await
|
||||
@@ -574,9 +554,7 @@ impl Imap {
|
||||
|
||||
let mut read_cnt = 0;
|
||||
loop {
|
||||
let (n, fetch_more) = self
|
||||
.fetch_new_msg_batch(context, session, folder, folder_meaning)
|
||||
.await?;
|
||||
let (n, fetch_more) = self.fetch_new_msg_batch(context, session, folder).await?;
|
||||
read_cnt += n;
|
||||
if !fetch_more {
|
||||
return Ok(read_cnt > 0);
|
||||
@@ -591,7 +569,6 @@ impl Imap {
|
||||
context: &Context,
|
||||
session: &mut Session,
|
||||
folder: &str,
|
||||
folder_meaning: FolderMeaning,
|
||||
) -> Result<(usize, bool)> {
|
||||
let transport_id = self.transport_id;
|
||||
let uid_validity = get_uidvalidity(context, transport_id, folder).await?;
|
||||
@@ -607,6 +584,7 @@ impl Imap {
|
||||
.await
|
||||
.context("prefetch")?;
|
||||
let read_cnt = msgs.len();
|
||||
let _fetch_msgs_lock_guard = context.fetch_msgs_mutex.lock().await;
|
||||
|
||||
let mut uids_fetch: Vec<u32> = Vec::new();
|
||||
let mut available_post_msgs: Vec<String> = Vec::new();
|
||||
@@ -660,13 +638,7 @@ impl Imap {
|
||||
info!(context, "Deleting locally deleted message {message_id}.");
|
||||
}
|
||||
|
||||
let _target;
|
||||
let target = if delete {
|
||||
""
|
||||
} else {
|
||||
_target = target_folder(context, folder, folder_meaning, &headers).await?;
|
||||
&_target
|
||||
};
|
||||
let target = if delete { "" } else { folder };
|
||||
|
||||
context
|
||||
.sql
|
||||
@@ -694,18 +666,9 @@ impl Imap {
|
||||
// message, move it to the movebox and then download the second message before
|
||||
// downloading the first one, if downloading from inbox before moving is allowed.
|
||||
if folder == target
|
||||
// Never download messages directly from the spam folder.
|
||||
// If the sender is known, the message will be moved to the Inbox or Mvbox
|
||||
// and then we download the message from there.
|
||||
// Also see `spam_target_folder_cfg()`.
|
||||
&& folder_meaning != FolderMeaning::Spam
|
||||
&& prefetch_should_download(
|
||||
context,
|
||||
&headers,
|
||||
&message_id,
|
||||
fetch_response.flags(),
|
||||
)
|
||||
.await.context("prefetch_should_download")?
|
||||
&& prefetch_should_download(context, &headers, &message_id, fetch_response.flags())
|
||||
.await
|
||||
.context("prefetch_should_download")?
|
||||
{
|
||||
if headers
|
||||
.get_header_value(HeaderDef::ChatIsPostMessage)
|
||||
@@ -1621,13 +1584,8 @@ impl Session {
|
||||
// Store new encrypted device token on the server
|
||||
// even if it is the same as the old one.
|
||||
if let Some(encrypted_device_token) = new_encrypted_device_token {
|
||||
let folder = context
|
||||
.get_config(Config::ConfiguredInboxFolder)
|
||||
.await?
|
||||
.context("INBOX is not configured")?;
|
||||
|
||||
self.run_command_and_check_ok(&format_setmetadata(
|
||||
&folder,
|
||||
"INBOX",
|
||||
&encrypted_device_token,
|
||||
))
|
||||
.await
|
||||
@@ -1672,117 +1630,6 @@ impl Session {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempts to configure mvbox.
|
||||
///
|
||||
/// Tries to find any folder examining `folders` in the order they go.
|
||||
/// This method does not use LIST command to ensure that
|
||||
/// configuration works even if mailbox lookup is forbidden via Access Control List (see
|
||||
/// <https://datatracker.ietf.org/doc/html/rfc4314>).
|
||||
///
|
||||
/// Returns first found folder name.
|
||||
async fn configure_mvbox<'a>(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
folders: &[&'a str],
|
||||
) -> Result<Option<&'a str>> {
|
||||
// Close currently selected folder if needed.
|
||||
// We are going to select folders using low-level EXAMINE operations below.
|
||||
self.maybe_close_folder(context).await?;
|
||||
|
||||
for folder in folders {
|
||||
info!(context, "Looking for MVBOX-folder \"{}\"...", &folder);
|
||||
let res = self.examine(&folder).await;
|
||||
if res.is_ok() {
|
||||
info!(
|
||||
context,
|
||||
"MVBOX-folder {:?} successfully selected, using it.", &folder
|
||||
);
|
||||
self.close().await?;
|
||||
// Before moving emails to the mvbox we need to remember its UIDVALIDITY, otherwise
|
||||
// emails moved before that wouldn't be fetched but considered "old" instead.
|
||||
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
|
||||
ensure!(folder_exists, "No MVBOX folder {:?}??", &folder);
|
||||
return Ok(Some(folder));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl Imap {
|
||||
pub(crate) async fn configure_folders(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
session: &mut Session,
|
||||
) -> Result<()> {
|
||||
let mut folders = session
|
||||
.list(Some(""), Some("*"))
|
||||
.await
|
||||
.context("list_folders failed")?;
|
||||
let mut delimiter = ".".to_string();
|
||||
let mut delimiter_is_default = true;
|
||||
let mut folder_configs = BTreeMap::new();
|
||||
|
||||
while let Some(folder) = folders.try_next().await? {
|
||||
info!(context, "Scanning folder: {:?}", folder);
|
||||
|
||||
// Update the delimiter iff there is a different one, but only once.
|
||||
if let Some(d) = folder.delimiter()
|
||||
&& delimiter_is_default
|
||||
&& !d.is_empty()
|
||||
&& delimiter != d
|
||||
{
|
||||
delimiter = d.to_string();
|
||||
delimiter_is_default = false;
|
||||
}
|
||||
|
||||
let folder_meaning = get_folder_meaning_by_attrs(folder.attributes());
|
||||
let folder_name_meaning = get_folder_meaning_by_name(folder.name());
|
||||
if let Some(config) = folder_meaning.to_config() {
|
||||
// Always takes precedence
|
||||
folder_configs.insert(config, folder.name().to_string());
|
||||
} else if let Some(config) = folder_name_meaning.to_config() {
|
||||
// only set if none has been already set
|
||||
folder_configs
|
||||
.entry(config)
|
||||
.or_insert_with(|| folder.name().to_string());
|
||||
}
|
||||
}
|
||||
drop(folders);
|
||||
|
||||
info!(context, "Using \"{}\" as folder-delimiter.", delimiter);
|
||||
|
||||
let fallback_folder = format!("INBOX{delimiter}DeltaChat");
|
||||
let mvbox_folder = session
|
||||
.configure_mvbox(context, &["DeltaChat", &fallback_folder])
|
||||
.await
|
||||
.context("failed to configure mvbox")?;
|
||||
|
||||
context
|
||||
.set_config_internal(Config::ConfiguredInboxFolder, Some("INBOX"))
|
||||
.await?;
|
||||
if let Some(mvbox_folder) = mvbox_folder {
|
||||
info!(context, "Setting MVBOX FOLDER TO {}", &mvbox_folder);
|
||||
context
|
||||
.set_config_internal(Config::ConfiguredMvboxFolder, Some(mvbox_folder))
|
||||
.await?;
|
||||
}
|
||||
for (config, name) in folder_configs {
|
||||
context.set_config_internal(config, Some(&name)).await?;
|
||||
}
|
||||
context
|
||||
.sql
|
||||
.set_raw_config_int(
|
||||
constants::DC_FOLDERS_CONFIGURED_KEY,
|
||||
constants::DC_FOLDERS_CONFIGURED_VERSION,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(context, "FINISHED configuring IMAP-folders.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -1916,15 +1763,7 @@ async fn spam_target_folder_cfg(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if needs_move_to_mvbox(context, headers).await?
|
||||
// If OnlyFetchMvbox is set, we don't want to move the message to
|
||||
// the inbox where we wouldn't fetch it again:
|
||||
|| context.get_config_bool(Config::OnlyFetchMvbox).await?
|
||||
{
|
||||
Ok(Some(Config::ConfiguredMvboxFolder))
|
||||
} else {
|
||||
Ok(Some(Config::ConfiguredInboxFolder))
|
||||
}
|
||||
Ok(Some(Config::ConfiguredInboxFolder))
|
||||
}
|
||||
|
||||
/// Returns `ConfiguredInboxFolder` or `ConfiguredMvboxFolder` if
|
||||
@@ -1935,16 +1774,12 @@ pub async fn target_folder_cfg(
|
||||
folder_meaning: FolderMeaning,
|
||||
headers: &[mailparse::MailHeader<'_>],
|
||||
) -> Result<Option<Config>> {
|
||||
if context.is_mvbox(folder).await? {
|
||||
if folder == "DeltaChat" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if folder_meaning == FolderMeaning::Spam {
|
||||
spam_target_folder_cfg(context, headers).await
|
||||
} else if folder_meaning == FolderMeaning::Inbox
|
||||
&& needs_move_to_mvbox(context, headers).await?
|
||||
{
|
||||
Ok(Some(Config::ConfiguredMvboxFolder))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -1965,36 +1800,6 @@ pub async fn target_folder(
|
||||
}
|
||||
}
|
||||
|
||||
async fn needs_move_to_mvbox(
|
||||
context: &Context,
|
||||
headers: &[mailparse::MailHeader<'_>],
|
||||
) -> Result<bool> {
|
||||
let has_chat_version = headers.get_header_value(HeaderDef::ChatVersion).is_some();
|
||||
if !context.get_config_bool(Config::MvboxMove).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if headers
|
||||
.get_header_value(HeaderDef::AutocryptSetupMessage)
|
||||
.is_some()
|
||||
{
|
||||
// do not move setup messages;
|
||||
// there may be a non-delta device that wants to handle it
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if has_chat_version {
|
||||
Ok(true)
|
||||
} else if let Some(parent) = get_prefetch_parent_message(context, headers).await? {
|
||||
match parent.is_dc_message {
|
||||
MessengerMessage::No => Ok(false),
|
||||
MessengerMessage::Yes | MessengerMessage::Reply => Ok(true),
|
||||
}
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get the folder meaning by the name of the folder only used if the server does not support XLIST.
|
||||
// TODO: lots languages missing - maybe there is a list somewhere on other MUAs?
|
||||
// however, if we fail to find out the sent-folder,
|
||||
@@ -2131,16 +1936,11 @@ pub(crate) async fn prefetch_should_download(
|
||||
false
|
||||
};
|
||||
|
||||
// Autocrypt Setup Message should be shown even if it is from non-chat client.
|
||||
let is_autocrypt_setup_message = headers
|
||||
.get_header_value(HeaderDef::AutocryptSetupMessage)
|
||||
.is_some();
|
||||
|
||||
let from = match mimeparser::get_from(headers) {
|
||||
Some(f) => f,
|
||||
None => return Ok(false),
|
||||
};
|
||||
let (_from_id, blocked_contact, origin) =
|
||||
let (_from_id, blocked_contact, _origin) =
|
||||
match from_field_to_contact_id(context, &from, None, true, true).await? {
|
||||
Some(res) => res,
|
||||
None => return Ok(false),
|
||||
@@ -2153,28 +1953,7 @@ pub(crate) async fn prefetch_should_download(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let is_chat_message = headers.get_header_value(HeaderDef::ChatVersion).is_some();
|
||||
let accepted_contact = origin.is_known();
|
||||
let is_reply_to_chat_message = get_prefetch_parent_message(context, headers)
|
||||
.await?
|
||||
.is_some_and(|parent| match parent.is_dc_message {
|
||||
MessengerMessage::No => false,
|
||||
MessengerMessage::Yes | MessengerMessage::Reply => true,
|
||||
});
|
||||
|
||||
let show_emails =
|
||||
ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?).unwrap_or_default();
|
||||
|
||||
let show = is_autocrypt_setup_message
|
||||
|| match show_emails {
|
||||
ShowEmails::Off => is_chat_message || is_reply_to_chat_message,
|
||||
ShowEmails::AcceptedContacts => {
|
||||
is_chat_message || is_reply_to_chat_message || accepted_contact
|
||||
}
|
||||
ShowEmails::All => true,
|
||||
};
|
||||
|
||||
let should_download = (show && !blocked_contact) || maybe_ndn;
|
||||
let should_download = (!blocked_contact) || maybe_ndn;
|
||||
Ok(should_download)
|
||||
}
|
||||
|
||||
@@ -2351,21 +2130,6 @@ async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Resul
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Whether to ignore fetching messages from a folder.
|
||||
///
|
||||
/// This caters for the [`Config::OnlyFetchMvbox`] setting which means mails from folders
|
||||
/// not explicitly watched should not be fetched.
|
||||
async fn should_ignore_folder(
|
||||
context: &Context,
|
||||
folder: &str,
|
||||
folder_meaning: FolderMeaning,
|
||||
) -> Result<bool> {
|
||||
if !context.get_config_bool(Config::OnlyFetchMvbox).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(!(context.is_mvbox(folder).await? || folder_meaning == FolderMeaning::Spam))
|
||||
}
|
||||
|
||||
/// Builds a list of sequence/uid sets. The returned sets have each no more than around 1000
|
||||
/// characters because according to <https://tools.ietf.org/html/rfc2683#section-3.2.1.5>
|
||||
/// command lines should not be much more than 1000 chars (servers should allow at least 8000 chars)
|
||||
@@ -2425,23 +2189,5 @@ impl std::fmt::Display for UidRange {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_watched_folder_configs(context: &Context) -> Result<Vec<Config>> {
|
||||
let mut res = vec![Config::ConfiguredInboxFolder];
|
||||
if context.should_watch_mvbox().await? {
|
||||
res.push(Config::ConfiguredMvboxFolder);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_watched_folders(context: &Context) -> Result<Vec<String>> {
|
||||
let mut res = Vec::new();
|
||||
for folder_config in get_watched_folder_configs(context).await? {
|
||||
if let Some(folder) = context.get_config(folder_config).await? {
|
||||
res.push(folder);
|
||||
}
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod imap_tests;
|
||||
|
||||
@@ -115,11 +115,7 @@ impl Session {
|
||||
|
||||
impl Imap {
|
||||
/// Idle using polling.
|
||||
pub(crate) async fn fake_idle(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
watch_folder: String,
|
||||
) -> Result<()> {
|
||||
pub(crate) async fn fake_idle(&mut self, context: &Context, watch_folder: &str) -> Result<()> {
|
||||
let fake_idle_start_time = tools::Time::now();
|
||||
|
||||
info!(context, "IMAP-fake-IDLEing folder={:?}", watch_folder);
|
||||
|
||||
@@ -100,7 +100,6 @@ fn test_build_sequence_sets() {
|
||||
|
||||
async fn check_target_folder_combination(
|
||||
folder: &str,
|
||||
mvbox_move: bool,
|
||||
chat_msg: bool,
|
||||
expected_destination: &str,
|
||||
accepted_chat: bool,
|
||||
@@ -108,16 +107,10 @@ async fn check_target_folder_combination(
|
||||
setupmessage: bool,
|
||||
) -> Result<()> {
|
||||
println!(
|
||||
"Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}"
|
||||
"Testing: For folder {folder}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}"
|
||||
);
|
||||
|
||||
let t = TestContext::new_alice().await;
|
||||
t.ctx
|
||||
.set_config(Config::ConfiguredMvboxFolder, Some("DeltaChat"))
|
||||
.await?;
|
||||
t.ctx
|
||||
.set_config(Config::MvboxMove, Some(if mvbox_move { "1" } else { "0" }))
|
||||
.await?;
|
||||
|
||||
if accepted_chat {
|
||||
let contact_id = Contact::create(&t.ctx, "", "bob@example.net").await?;
|
||||
@@ -164,42 +157,33 @@ async fn check_target_folder_combination(
|
||||
assert_eq!(
|
||||
expected,
|
||||
actual.as_deref(),
|
||||
"For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}"
|
||||
"For folder {folder}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// chat_msg means that the message was sent by Delta Chat
|
||||
// The tuples are (folder, mvbox_move, chat_msg, expected_destination)
|
||||
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
|
||||
("INBOX", false, false, "INBOX"),
|
||||
("INBOX", false, true, "INBOX"),
|
||||
("INBOX", true, false, "INBOX"),
|
||||
("INBOX", true, true, "DeltaChat"),
|
||||
("Spam", false, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
|
||||
("Spam", false, true, "INBOX"),
|
||||
("Spam", true, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
|
||||
("Spam", true, true, "DeltaChat"),
|
||||
// The tuples are (folder, chat_msg, expected_destination)
|
||||
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, &str)] = &[
|
||||
("INBOX", false, "INBOX"),
|
||||
("INBOX", true, "INBOX"),
|
||||
("Spam", false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
|
||||
("Spam", true, "INBOX"),
|
||||
];
|
||||
|
||||
// These are the same as above, but non-chat messages in Spam stay in Spam
|
||||
const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
|
||||
("INBOX", false, false, "INBOX"),
|
||||
("INBOX", false, true, "INBOX"),
|
||||
("INBOX", true, false, "INBOX"),
|
||||
("INBOX", true, true, "DeltaChat"),
|
||||
("Spam", false, false, "Spam"),
|
||||
("Spam", false, true, "INBOX"),
|
||||
("Spam", true, false, "Spam"),
|
||||
("Spam", true, true, "DeltaChat"),
|
||||
const COMBINATIONS_REQUEST: &[(&str, bool, &str)] = &[
|
||||
("INBOX", false, "INBOX"),
|
||||
("INBOX", true, "INBOX"),
|
||||
("Spam", false, "Spam"),
|
||||
("Spam", true, "INBOX"),
|
||||
];
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_target_folder_incoming_accepted() -> Result<()> {
|
||||
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
|
||||
for (folder, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
|
||||
check_target_folder_combination(
|
||||
folder,
|
||||
*mvbox_move,
|
||||
*chat_msg,
|
||||
expected_destination,
|
||||
true,
|
||||
@@ -213,10 +197,9 @@ async fn test_target_folder_incoming_accepted() -> Result<()> {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_target_folder_incoming_request() -> Result<()> {
|
||||
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
|
||||
for (folder, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
|
||||
check_target_folder_combination(
|
||||
folder,
|
||||
*mvbox_move,
|
||||
*chat_msg,
|
||||
expected_destination,
|
||||
false,
|
||||
@@ -231,17 +214,9 @@ async fn test_target_folder_incoming_request() -> Result<()> {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_target_folder_outgoing() -> Result<()> {
|
||||
// Test outgoing emails
|
||||
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
|
||||
check_target_folder_combination(
|
||||
folder,
|
||||
*mvbox_move,
|
||||
*chat_msg,
|
||||
expected_destination,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
for (folder, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
|
||||
check_target_folder_combination(folder, *chat_msg, expected_destination, true, true, false)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -249,10 +224,9 @@ async fn test_target_folder_outgoing() -> Result<()> {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_target_folder_setupmsg() -> Result<()> {
|
||||
// Test setupmessages
|
||||
for (folder, mvbox_move, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
|
||||
for (folder, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
|
||||
check_target_folder_combination(
|
||||
folder,
|
||||
*mvbox_move,
|
||||
*chat_msg,
|
||||
if folder == &"Spam" { "INBOX" } else { folder }, // Never move setup messages, except if they are in "Spam"
|
||||
false,
|
||||
|
||||
@@ -21,10 +21,8 @@ const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIE
|
||||
DATE \
|
||||
X-MICROSOFT-ORIGINAL-MESSAGE-ID \
|
||||
FROM \
|
||||
IN-REPLY-TO REFERENCES \
|
||||
CHAT-VERSION \
|
||||
CHAT-IS-POST-MESSAGE \
|
||||
AUTO-SUBMITTED \
|
||||
AUTOCRYPT-SETUP-MESSAGE\
|
||||
)])";
|
||||
|
||||
|
||||
186
src/imex.rs
186
src/imex.rs
@@ -19,9 +19,8 @@ use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
use crate::e2ee;
|
||||
use crate::events::EventType;
|
||||
use crate::key::{self, DcKey, SignedPublicKey, SignedSecretKey};
|
||||
use crate::key::{self, DcKey, SignedSecretKey};
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::pgp;
|
||||
use crate::qr::DCBACKUP_VERSION;
|
||||
use crate::sql;
|
||||
use crate::tools::{
|
||||
@@ -103,7 +102,8 @@ pub async fn imex(
|
||||
|
||||
if let Err(err) = res.as_ref() {
|
||||
// We are using Anyhow's .context() and to show the inner error, too, we need the {:#}:
|
||||
error!(context, "IMEX failed to complete: {:#}", err);
|
||||
error!(context, "{:#}", err);
|
||||
warn!(context, "IMEX failed to complete: {:#}", err);
|
||||
context.emit_event(EventType::ImexProgress(0));
|
||||
} else {
|
||||
info!(context, "IMEX successfully completed");
|
||||
@@ -141,19 +141,13 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result<String> {
|
||||
}
|
||||
|
||||
async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
|
||||
let private_key = SignedSecretKey::from_asc(armored)?;
|
||||
let public_key = private_key.to_public_key();
|
||||
|
||||
let keypair = pgp::KeyPair {
|
||||
public: public_key,
|
||||
secret: private_key,
|
||||
};
|
||||
key::store_self_keypair(context, &keypair).await?;
|
||||
let secret_key = SignedSecretKey::from_asc(armored)?;
|
||||
key::store_self_keypair(context, &secret_key).await?;
|
||||
|
||||
info!(
|
||||
context,
|
||||
"stored self key: {:?}",
|
||||
keypair.secret.public_key().legacy_key_id()
|
||||
"Stored self key: {:?}.",
|
||||
secret_key.public_key().fingerprint()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -209,15 +203,6 @@ async fn import_backup(
|
||||
backup_to_import: &Path,
|
||||
passphrase: String,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
!context.is_configured().await?,
|
||||
"Cannot import backups to accounts in use."
|
||||
);
|
||||
ensure!(
|
||||
!context.scheduler.is_running().await,
|
||||
"cannot import backup, IO is running"
|
||||
);
|
||||
|
||||
let backup_file = File::open(backup_to_import).await?;
|
||||
let file_size = backup_file.metadata().await?.len();
|
||||
info!(
|
||||
@@ -251,6 +236,15 @@ pub(crate) async fn import_backup_stream<R: tokio::io::AsyncRead + Unpin>(
|
||||
file_size: u64,
|
||||
passphrase: String,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
!context.is_configured().await?,
|
||||
"Cannot import backups to accounts in use"
|
||||
);
|
||||
ensure!(
|
||||
!context.scheduler.is_running().await,
|
||||
"Cannot import backup, IO is running"
|
||||
);
|
||||
|
||||
import_backup_stream_inner(context, backup_file, file_size, passphrase)
|
||||
.await
|
||||
.0
|
||||
@@ -317,6 +311,9 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// This function returns a tuple (Result<()>,) rather than Result<()>
|
||||
// so that we don't accidentally early-return with `?`
|
||||
// and forget to cleanup.
|
||||
async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
|
||||
context: &Context,
|
||||
backup_file: R,
|
||||
@@ -363,11 +360,6 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
|
||||
}
|
||||
}
|
||||
};
|
||||
if res.is_err() {
|
||||
for blob in blobs {
|
||||
fs::remove_file(&blob).await.log_err(context).ok();
|
||||
}
|
||||
}
|
||||
|
||||
let unpacked_database = context.get_blobdir().join(DBFILE_BACKUP_NAME);
|
||||
if res.is_ok() {
|
||||
@@ -390,6 +382,22 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
|
||||
res = context.sql.run_migrations(context).await;
|
||||
context.emit_event(EventType::AccountsItemChanged);
|
||||
}
|
||||
if res.is_err() {
|
||||
context.sql.close().await;
|
||||
fs::remove_file(context.sql.dbfile.as_path())
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
for blob in blobs {
|
||||
fs::remove_file(&blob).await.log_err(context).ok();
|
||||
}
|
||||
context
|
||||
.sql
|
||||
.open(context, "".to_string())
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
}
|
||||
if res.is_ok() {
|
||||
delete_and_reset_all_device_msgs(context)
|
||||
.await
|
||||
@@ -654,38 +662,36 @@ async fn export_self_keys(context: &Context, dir: &Path) -> Result<()> {
|
||||
let keys = context
|
||||
.sql
|
||||
.query_map_vec(
|
||||
"SELECT id, public_key, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;",
|
||||
"SELECT id, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;",
|
||||
(),
|
||||
|row| {
|
||||
let id = row.get(0)?;
|
||||
let public_key_blob: Vec<u8> = row.get(1)?;
|
||||
let public_key = SignedPublicKey::from_slice(&public_key_blob);
|
||||
let private_key_blob: Vec<u8> = row.get(2)?;
|
||||
let private_key_blob: Vec<u8> = row.get(1)?;
|
||||
let private_key = SignedSecretKey::from_slice(&private_key_blob);
|
||||
let is_default: i32 = row.get(3)?;
|
||||
let is_default: i32 = row.get(2)?;
|
||||
|
||||
Ok((id, public_key, private_key, is_default))
|
||||
Ok((id, private_key, is_default))
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let self_addr = context.get_primary_self_addr().await?;
|
||||
for (id, public_key, private_key, is_default) in keys {
|
||||
for (id, private_key, is_default) in keys {
|
||||
let id = Some(id).filter(|_| is_default == 0);
|
||||
|
||||
if let Ok(key) = public_key {
|
||||
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &key).await {
|
||||
error!(context, "Failed to export public key: {:#}.", err);
|
||||
export_errors += 1;
|
||||
}
|
||||
} else {
|
||||
let Ok(private_key) = private_key else {
|
||||
export_errors += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &private_key).await {
|
||||
error!(context, "Failed to export private key: {:#}.", err);
|
||||
export_errors += 1;
|
||||
}
|
||||
if let Ok(key) = private_key {
|
||||
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &key).await {
|
||||
error!(context, "Failed to export private key: {:#}.", err);
|
||||
export_errors += 1;
|
||||
}
|
||||
} else {
|
||||
|
||||
let public_key = private_key.to_public_key();
|
||||
|
||||
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &public_key).await {
|
||||
error!(context, "Failed to export public key: {:#}.", err);
|
||||
export_errors += 1;
|
||||
}
|
||||
}
|
||||
@@ -715,12 +721,7 @@ where
|
||||
format!("{kind}-key-{addr}-{id}-{fp}.asc")
|
||||
};
|
||||
let path = dir.join(&file_name);
|
||||
info!(
|
||||
context,
|
||||
"Exporting key {:?} to {}.",
|
||||
key.key_id(),
|
||||
path.display()
|
||||
);
|
||||
info!(context, "Exporting key to {}.", path.display());
|
||||
|
||||
// Delete the file if it already exists.
|
||||
delete_file(context, &path).await.ok();
|
||||
@@ -807,12 +808,12 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::test_utils::{TestContext, alice_keypair};
|
||||
use crate::test_utils::{TestContext, TestContextManager, alice_keypair};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_export_public_key_to_asc_file() {
|
||||
let context = TestContext::new().await;
|
||||
let key = alice_keypair().public;
|
||||
let key = alice_keypair().to_public_key();
|
||||
let blobdir = Path::new("$BLOBDIR");
|
||||
let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key)
|
||||
.await
|
||||
@@ -829,7 +830,7 @@ mod tests {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_import_private_key_exported_to_asc_file() {
|
||||
let context = TestContext::new().await;
|
||||
let key = alice_keypair().secret;
|
||||
let key = alice_keypair();
|
||||
let blobdir = Path::new("$BLOBDIR");
|
||||
let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key)
|
||||
.await
|
||||
@@ -1024,6 +1025,81 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that [`crate::qr::DCBACKUP_VERSION`] is checked correctly.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_import_backup_fails_because_of_dcbackup_version() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let context1 = tcm.alice().await;
|
||||
let context2 = tcm.unconfigured().await;
|
||||
|
||||
assert!(context1.is_configured().await?);
|
||||
assert!(!context2.is_configured().await?);
|
||||
|
||||
let backup_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
tcm.section("export from context1");
|
||||
assert!(
|
||||
imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
let _event = context1
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
|
||||
.await;
|
||||
let backup = has_backup(&context2, backup_dir.path()).await?;
|
||||
let modified_backup = backup_dir.path().join("modified_backup.tar");
|
||||
|
||||
tcm.section("Change backup_version to be higher than DCBACKUP_VERSION");
|
||||
{
|
||||
let unpack_dir = tempfile::tempdir().unwrap();
|
||||
let mut ar = Archive::new(File::open(&backup).await?);
|
||||
ar.unpack(&unpack_dir).await?;
|
||||
|
||||
let sql = sql::Sql::new(unpack_dir.path().join(DBFILE_BACKUP_NAME));
|
||||
sql.open(&context2, "".to_string()).await?;
|
||||
assert_eq!(
|
||||
sql.get_raw_config_int("backup_version").await?.unwrap(),
|
||||
DCBACKUP_VERSION
|
||||
);
|
||||
sql.set_raw_config_int("backup_version", DCBACKUP_VERSION + 1)
|
||||
.await?;
|
||||
sql.close().await;
|
||||
|
||||
let modified_backup_file = File::create(&modified_backup).await?;
|
||||
let mut builder = tokio_tar::Builder::new(modified_backup_file);
|
||||
builder.append_dir_all("", unpack_dir.path()).await?;
|
||||
builder.finish().await?;
|
||||
}
|
||||
|
||||
tcm.section("import to context2");
|
||||
let err = imex(&context2, ImexMode::ImportBackup, &modified_backup, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again"));
|
||||
|
||||
// Some UIs show the error from the event to the user.
|
||||
// Therefore, it must also be a user-facing string, rather than some technical info:
|
||||
let err_event = context2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::Error(_)))
|
||||
.await;
|
||||
let EventType::Error(err_msg) = err_event else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(err_msg.starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again"));
|
||||
|
||||
context2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::ImexProgress(0)))
|
||||
.await;
|
||||
|
||||
assert!(!context2.is_configured().await?);
|
||||
assert_eq!(context2.get_config(Config::ConfiguredAddr).await?, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This is a regression test for
|
||||
/// https://github.com/deltachat/deltachat-android/issues/2263
|
||||
/// where the config cache wasn't reset properly after a backup.
|
||||
|
||||
@@ -467,6 +467,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests that trying to accidentally overwrite a profile
|
||||
/// that is in use will fail.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cant_overwrite_profile_in_use() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let ctx0 = &tcm.alice().await;
|
||||
let ctx1 = &tcm.bob().await;
|
||||
|
||||
// Prepare to transfer backup.
|
||||
let provider = BackupProvider::prepare(ctx0).await?;
|
||||
|
||||
// Try to overwrite an existing profile.
|
||||
let err = get_backup(ctx1, provider.qr()).await.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("Cannot import backups to accounts in use"));
|
||||
|
||||
// ctx0 is supposed to also finish, and emit an error:
|
||||
provider.await.unwrap();
|
||||
ctx0.evtracker
|
||||
.get_matching(|e| matches!(e, EventType::Error(_)))
|
||||
.await;
|
||||
|
||||
assert_eq!(ctx1.get_primary_self_addr().await?, "bob@example.net");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_drop_provider() {
|
||||
let mut tcm = TestContextManager::new();
|
||||
|
||||
@@ -10,13 +10,12 @@ use crate::key;
|
||||
use crate::key::DcKey;
|
||||
use crate::mimeparser::MimeMessage;
|
||||
use crate::pgp;
|
||||
use crate::pgp::KeyPair;
|
||||
|
||||
pub fn key_from_asc(data: &str) -> Result<key::SignedSecretKey> {
|
||||
key::SignedSecretKey::from_asc(data)
|
||||
}
|
||||
|
||||
pub async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<()> {
|
||||
pub async fn store_self_keypair(context: &Context, keypair: &key::SignedSecretKey) -> Result<()> {
|
||||
key::store_self_keypair(context, keypair).await
|
||||
}
|
||||
|
||||
@@ -29,7 +28,7 @@ pub async fn save_broadcast_secret(context: &Context, chat_id: ChatId, secret: &
|
||||
crate::chat::save_broadcast_secret(context, chat_id, secret).await
|
||||
}
|
||||
|
||||
pub fn create_dummy_keypair(addr: &str) -> Result<KeyPair> {
|
||||
pub fn create_dummy_keypair(addr: &str) -> Result<key::SignedSecretKey> {
|
||||
pgp::create_keypair(EmailAddress::new(addr)?)
|
||||
}
|
||||
|
||||
|
||||
278
src/key.rs
278
src/key.rs
@@ -7,16 +7,23 @@ use std::io::Cursor;
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use base64::Engine as _;
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
use pgp::composed::Deserializable;
|
||||
use pgp::composed::{Deserializable, SignedKeyDetails};
|
||||
pub use pgp::composed::{SignedPublicKey, SignedSecretKey};
|
||||
use pgp::crypto::aead::AeadAlgorithm;
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||
use pgp::packet::{
|
||||
Features, KeyFlags, Notation, PacketTrait as _, SignatureConfig, SignatureType, Subpacket,
|
||||
SubpacketData,
|
||||
};
|
||||
use pgp::ser::Serialize;
|
||||
use pgp::types::{KeyDetails, KeyId};
|
||||
use pgp::types::{CompressionAlgorithm, KeyDetails, KeyVersion};
|
||||
use rand_old::thread_rng;
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::log::LogExt;
|
||||
use crate::pgp::KeyPair;
|
||||
use crate::tools::{self, time_elapsed};
|
||||
|
||||
/// Convenience trait for working with keys.
|
||||
@@ -113,19 +120,155 @@ pub trait DcKey: Serialize + Deserializable + Clone {
|
||||
|
||||
/// Whether the key is private (or public).
|
||||
fn is_private() -> bool;
|
||||
}
|
||||
|
||||
/// Returns the OpenPGP Key ID.
|
||||
fn key_id(&self) -> KeyId;
|
||||
/// Converts secret key to public key.
|
||||
pub(crate) fn secret_key_to_public_key(
|
||||
context: &Context,
|
||||
mut signed_secret_key: SignedSecretKey,
|
||||
timestamp: u32,
|
||||
addr: &str,
|
||||
relay_addrs: &str,
|
||||
) -> Result<SignedPublicKey> {
|
||||
info!(context, "Converting secret key to public key.");
|
||||
let timestamp = pgp::types::Timestamp::from_secs(timestamp);
|
||||
|
||||
// Subpackets that we want to share between DKS and User ID signature.
|
||||
let common_subpackets = || -> Result<Vec<Subpacket>> {
|
||||
let keyflags = {
|
||||
let mut keyflags = KeyFlags::default();
|
||||
keyflags.set_certify(true);
|
||||
keyflags.set_sign(true);
|
||||
keyflags
|
||||
};
|
||||
let features = {
|
||||
let mut features = Features::default();
|
||||
features.set_seipd_v1(true);
|
||||
features.set_seipd_v2(true);
|
||||
features
|
||||
};
|
||||
|
||||
Ok(vec![
|
||||
Subpacket::regular(SubpacketData::SignatureCreationTime(timestamp))?,
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(
|
||||
signed_secret_key.fingerprint(),
|
||||
))?,
|
||||
Subpacket::regular(SubpacketData::KeyFlags(keyflags))?,
|
||||
Subpacket::regular(SubpacketData::Features(features))?,
|
||||
Subpacket::regular(SubpacketData::PreferredSymmetricAlgorithms(smallvec![
|
||||
SymmetricKeyAlgorithm::AES256,
|
||||
SymmetricKeyAlgorithm::AES192,
|
||||
SymmetricKeyAlgorithm::AES128
|
||||
]))?,
|
||||
Subpacket::regular(SubpacketData::PreferredHashAlgorithms(smallvec![
|
||||
HashAlgorithm::Sha256,
|
||||
HashAlgorithm::Sha384,
|
||||
HashAlgorithm::Sha512,
|
||||
HashAlgorithm::Sha224,
|
||||
]))?,
|
||||
Subpacket::regular(SubpacketData::PreferredCompressionAlgorithms(smallvec![
|
||||
CompressionAlgorithm::ZLIB,
|
||||
CompressionAlgorithm::ZIP,
|
||||
]))?,
|
||||
Subpacket::regular(SubpacketData::PreferredAeadAlgorithms(smallvec![(
|
||||
SymmetricKeyAlgorithm::AES256,
|
||||
AeadAlgorithm::Ocb
|
||||
)]))?,
|
||||
Subpacket::regular(SubpacketData::IsPrimary(true))?,
|
||||
])
|
||||
};
|
||||
|
||||
// RFC 4880 required that Transferrable Public Key (aka OpenPGP Certificate)
|
||||
// contains at least one User ID:
|
||||
// <https://www.rfc-editor.org/rfc/rfc4880#section-11.1>
|
||||
// RFC 9580 does not require User ID even for V4 certificates anymore:
|
||||
// <https://www.rfc-editor.org/rfc/rfc9580.html#name-openpgp-version-4-certifica>
|
||||
//
|
||||
// We do not use and do not expect User ID in any keys,
|
||||
// but nevertheless include User ID in V4 keys for compatibility with clients that follow RFC 4880.
|
||||
// RFC 9580 also recommends including User ID into V4 keys:
|
||||
// <https://www.rfc-editor.org/rfc/rfc9580.html#section-5.2.3.10-8>
|
||||
//
|
||||
// We do not support keys older than V4 and are not going
|
||||
// to include User ID in newer V6 keys as all clients that support V6
|
||||
// should support keys without User ID.
|
||||
let users = if signed_secret_key.version() == KeyVersion::V4 {
|
||||
let user_id = format!("<{addr}>");
|
||||
|
||||
let mut rng = thread_rng();
|
||||
// Self-signature is a "positive certification",
|
||||
// see <https://www.ietf.org/archive/id/draft-gallagher-openpgp-signatures-02.html#name-certification-signature-typ>.
|
||||
let mut user_id_signature_config = SignatureConfig::from_key(
|
||||
&mut rng,
|
||||
&signed_secret_key.primary_key,
|
||||
SignatureType::CertPositive,
|
||||
)?;
|
||||
user_id_signature_config.hashed_subpackets = common_subpackets()?;
|
||||
user_id_signature_config.unhashed_subpackets = vec![Subpacket::regular(
|
||||
SubpacketData::IssuerKeyId(signed_secret_key.legacy_key_id()),
|
||||
)?];
|
||||
let user_id_packet =
|
||||
pgp::packet::UserId::from_str(pgp::types::PacketHeaderVersion::New, &user_id)?;
|
||||
let signature = user_id_signature_config.sign_certification(
|
||||
&signed_secret_key.primary_key,
|
||||
&signed_secret_key.primary_key.public_key(),
|
||||
&pgp::types::Password::empty(),
|
||||
user_id_packet.tag(),
|
||||
&user_id_packet,
|
||||
)?;
|
||||
vec![user_id_packet.into_signed(signature)]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let direct_signatures = {
|
||||
let mut rng = thread_rng();
|
||||
let mut direct_key_signature_config = SignatureConfig::from_key(
|
||||
&mut rng,
|
||||
&signed_secret_key.primary_key,
|
||||
SignatureType::Key,
|
||||
)?;
|
||||
direct_key_signature_config.hashed_subpackets = common_subpackets()?;
|
||||
let notation = Notation {
|
||||
readable: true,
|
||||
name: "relays@chatmail.at".into(),
|
||||
value: relay_addrs.to_string().into(),
|
||||
};
|
||||
direct_key_signature_config
|
||||
.hashed_subpackets
|
||||
.push(Subpacket::regular(SubpacketData::Notation(notation))?);
|
||||
let direct_key_signature = direct_key_signature_config.sign_key(
|
||||
&signed_secret_key.primary_key,
|
||||
&pgp::types::Password::empty(),
|
||||
signed_secret_key.primary_key.public_key(),
|
||||
)?;
|
||||
vec![direct_key_signature]
|
||||
};
|
||||
|
||||
signed_secret_key.details = SignedKeyDetails {
|
||||
revocation_signatures: vec![],
|
||||
direct_signatures,
|
||||
users,
|
||||
user_attributes: vec![],
|
||||
};
|
||||
|
||||
Ok(signed_secret_key.to_public_key())
|
||||
}
|
||||
|
||||
/// Attempts to load own public key.
|
||||
///
|
||||
/// Returns `None` if no key is generated yet.
|
||||
/// Returns `None` if no secret key is generated yet.
|
||||
pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option<SignedPublicKey>> {
|
||||
let Some(public_key_bytes) = context
|
||||
let mut lock = context.self_public_key.lock().await;
|
||||
|
||||
if let Some(ref public_key) = *lock {
|
||||
return Ok(Some(public_key.clone()));
|
||||
}
|
||||
|
||||
let Some(secret_key_bytes) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT public_key
|
||||
"SELECT private_key
|
||||
FROM keypairs
|
||||
WHERE id=(SELECT value FROM config WHERE keyname='key_id')",
|
||||
(),
|
||||
@@ -138,8 +281,27 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
|
||||
Ok(Some(public_key))
|
||||
let signed_secret_key = SignedSecretKey::from_slice(&secret_key_bytes)?;
|
||||
let timestamp = context
|
||||
.sql
|
||||
.query_get_value::<u32>(
|
||||
"SELECT MAX(timestamp)
|
||||
FROM (SELECT add_timestamp AS timestamp
|
||||
FROM transports
|
||||
UNION ALL
|
||||
SELECT remove_timestamp AS timestamp
|
||||
FROM removed_transports)",
|
||||
(),
|
||||
)
|
||||
.await?
|
||||
.context("No transports configured")?;
|
||||
let addr = context.get_primary_self_addr().await?;
|
||||
let all_addrs = context.get_all_self_addrs().await?.join(",");
|
||||
let signed_public_key =
|
||||
secret_key_to_public_key(context, signed_secret_key, timestamp, &addr, &all_addrs)?;
|
||||
*lock = Some(signed_public_key.clone());
|
||||
|
||||
Ok(Some(signed_public_key))
|
||||
}
|
||||
|
||||
/// Loads own public key.
|
||||
@@ -149,8 +311,11 @@ pub(crate) async fn load_self_public_key(context: &Context) -> Result<SignedPubl
|
||||
match load_self_public_key_opt(context).await? {
|
||||
Some(public_key) => Ok(public_key),
|
||||
None => {
|
||||
let keypair = generate_keypair(context).await?;
|
||||
Ok(keypair.public)
|
||||
generate_keypair(context).await?;
|
||||
let public_key = load_self_public_key_opt(context)
|
||||
.await?
|
||||
.context("Secret key generated, but public key cannot be created")?;
|
||||
Ok(public_key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,8 +380,8 @@ pub(crate) async fn load_self_secret_key(context: &Context) -> Result<SignedSecr
|
||||
match private_key {
|
||||
Some(bytes) => SignedSecretKey::from_slice(&bytes),
|
||||
None => {
|
||||
let keypair = generate_keypair(context).await?;
|
||||
Ok(keypair.secret)
|
||||
let secret = generate_keypair(context).await?;
|
||||
Ok(secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,10 +427,6 @@ impl DcKey for SignedPublicKey {
|
||||
fn dc_fingerprint(&self) -> Fingerprint {
|
||||
self.fingerprint().into()
|
||||
}
|
||||
|
||||
fn key_id(&self) -> KeyId {
|
||||
KeyDetails::legacy_key_id(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl DcKey for SignedSecretKey {
|
||||
@@ -289,16 +450,12 @@ impl DcKey for SignedSecretKey {
|
||||
fn dc_fingerprint(&self) -> Fingerprint {
|
||||
self.fingerprint().into()
|
||||
}
|
||||
|
||||
fn key_id(&self) -> KeyId {
|
||||
KeyDetails::legacy_key_id(&**self)
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_keypair(context: &Context) -> Result<KeyPair> {
|
||||
async fn generate_keypair(context: &Context) -> Result<SignedSecretKey> {
|
||||
let addr = context.get_primary_self_addr().await?;
|
||||
let addr = EmailAddress::new(&addr)?;
|
||||
let _guard = context.generating_key_mutex.lock().await;
|
||||
let _public_key_guard = context.self_public_key.lock().await;
|
||||
|
||||
// Check if the key appeared while we were waiting on the lock.
|
||||
match load_keypair(context).await? {
|
||||
@@ -321,51 +478,52 @@ async fn generate_keypair(context: &Context) -> Result<KeyPair> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_keypair(context: &Context) -> Result<Option<KeyPair>> {
|
||||
pub(crate) async fn load_keypair(context: &Context) -> Result<Option<SignedSecretKey>> {
|
||||
let res = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT public_key, private_key
|
||||
FROM keypairs
|
||||
WHERE id=(SELECT value FROM config WHERE keyname='key_id')",
|
||||
"SELECT private_key
|
||||
FROM keypairs
|
||||
WHERE id=(SELECT value FROM config WHERE keyname='key_id')",
|
||||
(),
|
||||
|row| {
|
||||
let pub_bytes: Vec<u8> = row.get(0)?;
|
||||
let sec_bytes: Vec<u8> = row.get(1)?;
|
||||
Ok((pub_bytes, sec_bytes))
|
||||
let sec_bytes: Vec<u8> = row.get(0)?;
|
||||
Ok(sec_bytes)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(if let Some((pub_bytes, sec_bytes)) = res {
|
||||
Some(KeyPair {
|
||||
public: SignedPublicKey::from_slice(&pub_bytes)?,
|
||||
secret: SignedSecretKey::from_slice(&sec_bytes)?,
|
||||
})
|
||||
let signed_secret_key = if let Some(sec_bytes) = res {
|
||||
Some(SignedSecretKey::from_slice(&sec_bytes)?)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
};
|
||||
|
||||
Ok(signed_secret_key)
|
||||
}
|
||||
|
||||
/// Store the keypair as an owned keypair for addr in the database.
|
||||
/// Stores own keypair in the database and sets it as a default.
|
||||
///
|
||||
/// This will save the keypair as keys for the given address. The
|
||||
/// "self" here refers to the fact that this DC instance owns the
|
||||
/// keypair. Usually `addr` will be [Config::ConfiguredAddr].
|
||||
///
|
||||
/// If either the public or private keys are already present in the
|
||||
/// database, this entry will be removed first regardless of the
|
||||
/// address associated with it. Practically this means saving the
|
||||
/// same key again overwrites it.
|
||||
///
|
||||
/// [Config::ConfiguredAddr]: crate::config::Config::ConfiguredAddr
|
||||
pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<()> {
|
||||
/// Fails if we already have a key, so it is not possible to
|
||||
/// have more than one key for new setups. Existing setups
|
||||
/// may still have more than one key for compatibility.
|
||||
pub(crate) async fn store_self_keypair(
|
||||
context: &Context,
|
||||
signed_secret_key: &SignedSecretKey,
|
||||
) -> Result<()> {
|
||||
// This public key is stored in the database
|
||||
// only for backwards compatibility.
|
||||
//
|
||||
// It should not be used e.g. in Autocrypt headers or vCards.
|
||||
// Use `secret_key_to_public_key()` function instead,
|
||||
// which adds relay list to the signature.
|
||||
let signed_public_key = signed_secret_key.to_public_key();
|
||||
let mut config_cache_lock = context.sql.config_cache.write().await;
|
||||
let new_key_id = context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
let public_key = DcKey::to_bytes(&keypair.public);
|
||||
let secret_key = DcKey::to_bytes(&keypair.secret);
|
||||
let public_key = DcKey::to_bytes(&signed_public_key);
|
||||
let secret_key = DcKey::to_bytes(signed_secret_key);
|
||||
|
||||
// private_key and public_key columns
|
||||
// are UNIQUE since migration 107,
|
||||
@@ -403,9 +561,7 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) ->
|
||||
/// Use import/export APIs instead.
|
||||
pub async fn preconfigure_keypair(context: &Context, secret_data: &str) -> Result<()> {
|
||||
let secret = SignedSecretKey::from_asc(secret_data)?;
|
||||
let public = secret.to_public_key();
|
||||
let keypair = KeyPair { public, secret };
|
||||
store_self_keypair(context, &keypair).await?;
|
||||
store_self_keypair(context, &secret).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -484,7 +640,7 @@ mod tests {
|
||||
use crate::config::Config;
|
||||
use crate::test_utils::{TestContext, alice_keypair};
|
||||
|
||||
static KEYPAIR: LazyLock<KeyPair> = LazyLock::new(alice_keypair);
|
||||
static KEYPAIR: LazyLock<SignedSecretKey> = LazyLock::new(alice_keypair);
|
||||
|
||||
#[test]
|
||||
fn test_from_armored_string() {
|
||||
@@ -554,12 +710,12 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
|
||||
#[test]
|
||||
fn test_asc_roundtrip() {
|
||||
let key = KEYPAIR.public.clone();
|
||||
let key = KEYPAIR.clone().to_public_key();
|
||||
let asc = key.to_asc(Some(("spam", "ham")));
|
||||
let key2 = SignedPublicKey::from_asc(&asc).unwrap();
|
||||
assert_eq!(key, key2);
|
||||
|
||||
let key = KEYPAIR.secret.clone();
|
||||
let key = KEYPAIR.clone();
|
||||
let asc = key.to_asc(Some(("spam", "ham")));
|
||||
let key2 = SignedSecretKey::from_asc(&asc).unwrap();
|
||||
assert_eq!(key, key2);
|
||||
@@ -567,8 +723,8 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
|
||||
#[test]
|
||||
fn test_from_slice_roundtrip() {
|
||||
let public_key = KEYPAIR.public.clone();
|
||||
let private_key = KEYPAIR.secret.clone();
|
||||
let private_key = KEYPAIR.clone();
|
||||
let public_key = KEYPAIR.clone().to_public_key();
|
||||
|
||||
let binary = DcKey::to_bytes(&public_key);
|
||||
let public_key2 = SignedPublicKey::from_slice(&binary).expect("invalid public key");
|
||||
@@ -610,7 +766,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
b"\x02\xfc\xaa".as_slice(),
|
||||
b"\x01\x02\x03\x04\x05".as_slice(),
|
||||
] {
|
||||
let private_key = KEYPAIR.secret.clone();
|
||||
let private_key = KEYPAIR.clone();
|
||||
|
||||
let mut binary = DcKey::to_bytes(&private_key);
|
||||
binary.extend(garbage);
|
||||
@@ -624,7 +780,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
|
||||
#[test]
|
||||
fn test_base64_roundtrip() {
|
||||
let key = KEYPAIR.public.clone();
|
||||
let key = KEYPAIR.clone().to_public_key();
|
||||
let base64 = key.to_base64();
|
||||
let key2 = SignedPublicKey::from_base64(&base64).unwrap();
|
||||
assert_eq!(key, key2);
|
||||
|
||||
@@ -56,9 +56,37 @@ pub enum EnteredCertificateChecks {
|
||||
AcceptInvalidCertificates2 = 3,
|
||||
}
|
||||
|
||||
/// Login parameters for a single server, either IMAP or SMTP
|
||||
/// Login parameters for a single IMAP server.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EnteredServerLoginParam {
|
||||
pub struct EnteredImapLoginParam {
|
||||
/// Server hostname or IP address.
|
||||
pub server: String,
|
||||
|
||||
/// Server port.
|
||||
///
|
||||
/// 0 if not specified.
|
||||
pub port: u16,
|
||||
|
||||
/// Folder to watch.
|
||||
///
|
||||
/// If empty, user has not entered anything and it shuold expand to "INBOX" later.
|
||||
pub folder: String,
|
||||
|
||||
/// Socket security.
|
||||
pub security: Socket,
|
||||
|
||||
/// Username.
|
||||
///
|
||||
/// Empty string if not specified.
|
||||
pub user: String,
|
||||
|
||||
/// Password.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Login parameters for a single SMTP server.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EnteredSmtpLoginParam {
|
||||
/// Server hostname or IP address.
|
||||
pub server: String,
|
||||
|
||||
@@ -86,10 +114,10 @@ pub struct EnteredLoginParam {
|
||||
pub addr: String,
|
||||
|
||||
/// IMAP settings.
|
||||
pub imap: EnteredServerLoginParam,
|
||||
pub imap: EnteredImapLoginParam,
|
||||
|
||||
/// SMTP settings.
|
||||
pub smtp: EnteredServerLoginParam,
|
||||
pub smtp: EnteredSmtpLoginParam,
|
||||
|
||||
/// TLS options: whether to allow invalid certificates and/or
|
||||
/// invalid hostnames
|
||||
@@ -101,6 +129,8 @@ pub struct EnteredLoginParam {
|
||||
|
||||
impl EnteredLoginParam {
|
||||
/// Loads entered account settings.
|
||||
///
|
||||
/// This is a legacy API for loading from separate config parameters.
|
||||
pub(crate) async fn load(context: &Context) -> Result<Self> {
|
||||
let addr = context
|
||||
.get_config(Config::Addr)
|
||||
@@ -117,6 +147,10 @@ impl EnteredLoginParam {
|
||||
.get_config_parsed::<u16>(Config::MailPort)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
// There is no way to set custom folder with this legacy API.
|
||||
let mail_folder = String::new();
|
||||
|
||||
let mail_security = context
|
||||
.get_config_parsed::<i32>(Config::MailSecurity)
|
||||
.await?
|
||||
@@ -175,14 +209,15 @@ impl EnteredLoginParam {
|
||||
|
||||
Ok(EnteredLoginParam {
|
||||
addr,
|
||||
imap: EnteredServerLoginParam {
|
||||
imap: EnteredImapLoginParam {
|
||||
server: mail_server,
|
||||
port: mail_port,
|
||||
folder: mail_folder,
|
||||
security: mail_security,
|
||||
user: mail_user,
|
||||
password: mail_pw,
|
||||
},
|
||||
smtp: EnteredServerLoginParam {
|
||||
smtp: EnteredSmtpLoginParam {
|
||||
server: send_server,
|
||||
port: send_port,
|
||||
security: send_security,
|
||||
@@ -344,14 +379,15 @@ mod tests {
|
||||
let t = TestContext::new().await;
|
||||
let param = EnteredLoginParam {
|
||||
addr: "alice@example.org".to_string(),
|
||||
imap: EnteredServerLoginParam {
|
||||
imap: EnteredImapLoginParam {
|
||||
server: "".to_string(),
|
||||
port: 0,
|
||||
folder: "".to_string(),
|
||||
security: Socket::Starttls,
|
||||
user: "".to_string(),
|
||||
password: "foobar".to_string(),
|
||||
},
|
||||
smtp: EnteredServerLoginParam {
|
||||
smtp: EnteredSmtpLoginParam {
|
||||
server: "".to_string(),
|
||||
port: 2947,
|
||||
security: Socket::default(),
|
||||
|
||||
@@ -1934,6 +1934,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
|
||||
// We also don't send read receipts for contact requests.
|
||||
// Read receipts will not be sent even after accepting the chat.
|
||||
let to_id = if curr_blocked == Blocked::Not
|
||||
&& !curr_hidden
|
||||
&& curr_param.get_bool(Param::WantsMdn).unwrap_or_default()
|
||||
&& curr_param.get_cmd() == SystemMessage::Unknown
|
||||
&& context.should_send_mdns().await?
|
||||
@@ -2067,6 +2068,22 @@ pub(crate) async fn set_msg_failed(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserts a tombstone into `msgs` table
|
||||
/// to prevent downloading the same message in the future.
|
||||
///
|
||||
/// Returns tombstone database row ID.
|
||||
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) VALUES (?,?)",
|
||||
(rfc724_mid, DC_CHAT_ID_TRASH),
|
||||
)
|
||||
.await?;
|
||||
let msg_id = MsgId::new(u32::try_from(row_id)?);
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
||||
/// The number of messages assigned to unblocked chats
|
||||
pub async fn get_unblocked_msg_cnt(context: &Context) -> usize {
|
||||
match context
|
||||
|
||||
@@ -33,7 +33,7 @@ use crate::message::{Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{SystemMessage, is_hidden};
|
||||
use crate::param::Param;
|
||||
use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
|
||||
use crate::pgp::SeipdVersion;
|
||||
use crate::pgp::{SeipdVersion, addresses_from_public_key};
|
||||
use crate::simplify::escape_message_footer_marks;
|
||||
use crate::stock_str;
|
||||
use crate::tools::{
|
||||
@@ -274,10 +274,13 @@ impl MimeFactory {
|
||||
.await?
|
||||
.context("Can't send member addition/removal: missing key")?;
|
||||
|
||||
recipients.push(addr.clone());
|
||||
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
|
||||
|
||||
let relays =
|
||||
addresses_from_public_key(&public_key).unwrap_or_else(|| vec![addr.clone()]);
|
||||
recipients.extend(relays);
|
||||
to.push((authname, addr.clone()));
|
||||
|
||||
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
|
||||
encryption_pubkeys = Some(vec![(addr, public_key)]);
|
||||
} else {
|
||||
let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
|
||||
@@ -354,9 +357,23 @@ impl MimeFactory {
|
||||
false => "".to_string(),
|
||||
};
|
||||
if add_timestamp >= remove_timestamp {
|
||||
let relays = if let Some(public_key) = public_key_opt {
|
||||
let addrs = addresses_from_public_key(&public_key);
|
||||
keys.push((addr.clone(), public_key));
|
||||
addrs
|
||||
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
if is_encrypted {
|
||||
warn!(context, "Missing key for {addr}");
|
||||
}
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}.unwrap_or_else(|| vec![addr.clone()]);
|
||||
|
||||
if !recipients_contain_addr(&to, &addr) {
|
||||
if id != ContactId::SELF {
|
||||
recipients.push(addr.clone());
|
||||
recipients.extend(relays);
|
||||
}
|
||||
if !undisclosed_recipients {
|
||||
to.push((name, addr.clone()));
|
||||
@@ -367,42 +384,38 @@ impl MimeFactory {
|
||||
} else if id == ContactId::SELF {
|
||||
member_fingerprints.push(self_fingerprint.to_string());
|
||||
} else {
|
||||
ensure_and_debug_assert!(member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too");
|
||||
ensure_and_debug_assert!(member_fingerprints.is_empty(), "If some member is a key-contact, all other members should be key-contacts too");
|
||||
}
|
||||
}
|
||||
member_timestamps.push(add_timestamp);
|
||||
}
|
||||
}
|
||||
recipient_ids.insert(id);
|
||||
|
||||
if let Some(public_key) = public_key_opt {
|
||||
keys.push((addr.clone(), public_key))
|
||||
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
if is_encrypted {
|
||||
warn!(context, "Missing key for {addr}");
|
||||
}
|
||||
}
|
||||
} else if remove_timestamp.saturating_add(60 * 24 * 3600) > now {
|
||||
// Row is a tombstone,
|
||||
// member is not actually part of the group.
|
||||
if !recipients_contain_addr(&past_members, &addr) {
|
||||
if let Some(email_to_remove) = email_to_remove
|
||||
&& email_to_remove == addr {
|
||||
// This is a "member removed" message,
|
||||
// we need to notify removed member
|
||||
// that it was removed.
|
||||
if id != ContactId::SELF {
|
||||
recipients.push(addr.clone());
|
||||
}
|
||||
|
||||
if let Some(public_key) = public_key_opt {
|
||||
keys.push((addr.clone(), public_key))
|
||||
let relays = if let Some(public_key) = public_key_opt {
|
||||
let addrs = addresses_from_public_key(&public_key);
|
||||
keys.push((addr.clone(), public_key));
|
||||
addrs
|
||||
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
if is_encrypted {
|
||||
warn!(context, "Missing key for {addr}");
|
||||
}
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}.unwrap_or_else(|| vec![addr.clone()]);
|
||||
|
||||
// This is a "member removed" message,
|
||||
// we need to notify removed member
|
||||
// that it was removed.
|
||||
if id != ContactId::SELF {
|
||||
recipients.extend(relays);
|
||||
}
|
||||
}
|
||||
if !undisclosed_recipients {
|
||||
@@ -456,9 +469,16 @@ impl MimeFactory {
|
||||
.filter(|id| *id != ContactId::SELF)
|
||||
.collect();
|
||||
if recipient_ids.len() == 1
|
||||
&& msg.param.get_cmd() != SystemMessage::MemberRemovedFromGroup
|
||||
&& chat.typ != Chattype::OutBroadcast
|
||||
&& !matches!(
|
||||
msg.param.get_cmd(),
|
||||
SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage
|
||||
)
|
||||
&& !matches!(chat.typ, Chattype::OutBroadcast | Chattype::InBroadcast)
|
||||
{
|
||||
info!(
|
||||
context,
|
||||
"Scale up origin of {} recipients to OutgoingTo.", chat.id
|
||||
);
|
||||
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
|
||||
}
|
||||
|
||||
@@ -871,16 +891,6 @@ impl MimeFactory {
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
|
||||
));
|
||||
} else if let Loaded::Message { msg, .. } = &self.loaded
|
||||
&& msg.param.get_cmd() == SystemMessage::SecurejoinMessage
|
||||
{
|
||||
let step = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
if step != "vg-request" && step != "vc-request" {
|
||||
headers.push((
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Loaded::Message { msg, chat } = &self.loaded
|
||||
@@ -949,6 +959,22 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
|
||||
if self.pre_message_mode == PreMessageMode::Post {
|
||||
headers.push((
|
||||
"Chat-Is-Post-Message",
|
||||
mail_builder::headers::raw::Raw::new("1").into(),
|
||||
));
|
||||
} else if let PreMessageMode::Pre {
|
||||
post_msg_rfc724_mid,
|
||||
} = &self.pre_message_mode
|
||||
{
|
||||
headers.push((
|
||||
"Chat-Post-Message-ID",
|
||||
mail_builder::headers::message_id::MessageId::new(post_msg_rfc724_mid.clone())
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let is_encrypted = self.will_be_encrypted();
|
||||
|
||||
// Add ephemeral timer for non-MDN messages.
|
||||
@@ -995,189 +1021,29 @@ impl MimeFactory {
|
||||
Loaded::Mdn { .. } => self.render_mdn()?,
|
||||
};
|
||||
|
||||
// Split headers based on header confidentiality policy.
|
||||
|
||||
// Headers that must go into IMF header section.
|
||||
//
|
||||
// These are standard headers such as Date, In-Reply-To, References, which cannot be placed
|
||||
// anywhere else according to the standard. Placing headers here also allows them to be fetched
|
||||
// individually over IMAP without downloading the message body. This is why Chat-Version is
|
||||
// placed here.
|
||||
let mut unprotected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// Headers that MUST NOT (only) go into IMF header section:
|
||||
// - Large headers which may hit the header section size limit on the server, such as
|
||||
// Chat-User-Avatar with a base64-encoded image inside.
|
||||
// - Headers duplicated here that servers mess up with in the IMF header section, like
|
||||
// Message-ID.
|
||||
// - Nonstandard headers that should be DKIM-protected because e.g. OpenDKIM only signs
|
||||
// known headers.
|
||||
//
|
||||
// The header should be hidden from MTA
|
||||
// by moving it either into protected part
|
||||
// in case of encrypted mails
|
||||
// or unprotected MIME preamble in case of unencrypted mails.
|
||||
let mut hidden_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// Opportunistically protected headers.
|
||||
//
|
||||
// These headers are placed into encrypted part *if* the message is encrypted. Place headers
|
||||
// which are not needed before decryption (e.g. Chat-Group-Name) or are not interesting if the
|
||||
// message cannot be decrypted (e.g. Chat-Disposition-Notification-To) here.
|
||||
//
|
||||
// If the message is not encrypted, these headers are placed into IMF header section, so make
|
||||
// sure that the message will be encrypted if you place any sensitive information here.
|
||||
let mut protected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// MIME header <https://datatracker.ietf.org/doc/html/rfc2045>.
|
||||
unprotected_headers.push((
|
||||
"MIME-Version",
|
||||
mail_builder::headers::raw::Raw::new("1.0").into(),
|
||||
));
|
||||
|
||||
if self.pre_message_mode == PreMessageMode::Post {
|
||||
unprotected_headers.push((
|
||||
"Chat-Is-Post-Message",
|
||||
mail_builder::headers::raw::Raw::new("1").into(),
|
||||
));
|
||||
} else if let PreMessageMode::Pre {
|
||||
post_msg_rfc724_mid,
|
||||
} = &self.pre_message_mode
|
||||
{
|
||||
protected_headers.push((
|
||||
"Chat-Post-Message-ID",
|
||||
mail_builder::headers::message_id::MessageId::new(post_msg_rfc724_mid.clone())
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
for header @ (original_header_name, _header_value) in &headers {
|
||||
let header_name = original_header_name.to_lowercase();
|
||||
if header_name == "message-id" {
|
||||
unprotected_headers.push(header.clone());
|
||||
hidden_headers.push(header.clone());
|
||||
} else if is_hidden(&header_name) {
|
||||
hidden_headers.push(header.clone());
|
||||
} else if header_name == "from" {
|
||||
// Unencrypted securejoin messages should _not_ include the display name:
|
||||
if is_encrypted || !is_securejoin_message {
|
||||
protected_headers.push(header.clone());
|
||||
}
|
||||
|
||||
unprotected_headers.push((
|
||||
original_header_name,
|
||||
Address::new_address(None::<&'static str>, self.from_addr.clone()).into(),
|
||||
));
|
||||
} else if header_name == "to" {
|
||||
protected_headers.push(header.clone());
|
||||
if is_encrypted {
|
||||
unprotected_headers.push(("To", hidden_recipients().into()));
|
||||
} else {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
} else if header_name == "chat-broadcast-secret" {
|
||||
if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
} else {
|
||||
bail!("Message is unecrypted, cannot include broadcast secret");
|
||||
}
|
||||
} else if is_encrypted && header_name == "date" {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
// Randomized date goes to unprotected header.
|
||||
//
|
||||
// We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000"
|
||||
// or omit the header because GMX then fails with
|
||||
//
|
||||
// host mx00.emig.gmx.net[212.227.15.9] said:
|
||||
// 554-Transaction failed
|
||||
// 554-Reject due to policy restrictions.
|
||||
// 554 For explanation visit https://postmaster.gmx.net/en/case?...
|
||||
// (in reply to end of DATA command)
|
||||
//
|
||||
// and the explanation page says
|
||||
// "The time information deviates too much from the actual time".
|
||||
//
|
||||
// We also limit the range to 6 days (518400 seconds)
|
||||
// because with a larger range we got
|
||||
// error "500 Date header far in the past/future"
|
||||
// which apparently originates from Symantec Messaging Gateway
|
||||
// and means the message has a Date that is more
|
||||
// than 7 days in the past:
|
||||
// <https://github.com/chatmail/core/issues/7466>
|
||||
let timestamp_offset = rand::random_range(0..518400);
|
||||
let protected_timestamp = self.timestamp.saturating_sub(timestamp_offset);
|
||||
let unprotected_date =
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(protected_timestamp, 0)
|
||||
.unwrap()
|
||||
.to_rfc2822();
|
||||
unprotected_headers.push((
|
||||
"Date",
|
||||
mail_builder::headers::raw::Raw::new(unprotected_date).into(),
|
||||
));
|
||||
} else if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
match header_name.as_str() {
|
||||
"subject" => {
|
||||
unprotected_headers.push((
|
||||
"Subject",
|
||||
mail_builder::headers::raw::Raw::new("[...]").into(),
|
||||
));
|
||||
}
|
||||
"in-reply-to"
|
||||
| "references"
|
||||
| "auto-submitted"
|
||||
| "chat-version"
|
||||
| "autocrypt-setup-message" => {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
_ => {
|
||||
// Other headers are removed from unprotected part.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Copy the header to the protected headers
|
||||
// in case of signed-only message.
|
||||
// If the message is not signed, this value will not be used.
|
||||
protected_headers.push(header.clone());
|
||||
unprotected_headers.push(header.clone())
|
||||
}
|
||||
}
|
||||
let HeadersByConfidentiality {
|
||||
mut unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
} = group_headers_by_confidentiality(
|
||||
headers,
|
||||
&self.from_addr,
|
||||
self.timestamp,
|
||||
is_encrypted,
|
||||
is_securejoin_message,
|
||||
);
|
||||
|
||||
let use_std_header_protection = context
|
||||
.get_config_bool(Config::StdHeaderProtectionComposing)
|
||||
.await?;
|
||||
let outer_message = if let Some(encryption_pubkeys) = self.encryption_pubkeys {
|
||||
// Store protected headers in the inner message.
|
||||
let message = protected_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
// Add hidden headers to encrypted payload.
|
||||
let mut message: MimePart<'static> = hidden_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
if use_std_header_protection {
|
||||
message = unprotected_headers
|
||||
.iter()
|
||||
// Structural headers shouldn't be added as "HP-Outer". They are defined in
|
||||
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
|
||||
.filter(|(name, _)| {
|
||||
!(name.eq_ignore_ascii_case("mime-version")
|
||||
|| name.eq_ignore_ascii_case("content-type")
|
||||
|| name.eq_ignore_ascii_case("content-transfer-encoding")
|
||||
|| name.eq_ignore_ascii_case("content-disposition"))
|
||||
})
|
||||
.fold(message, |message, (name, value)| {
|
||||
message.header(format!("HP-Outer: {name}"), value.clone())
|
||||
});
|
||||
}
|
||||
let mut message = add_headers_to_encrypted_part(
|
||||
message,
|
||||
&unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
use_std_header_protection,
|
||||
);
|
||||
|
||||
// Add gossip headers in chats with multiple recipients
|
||||
let multiple_recipients =
|
||||
@@ -1268,21 +1134,6 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// Set the appropriate Content-Type for the inner message.
|
||||
for (h, v) in &mut message.headers {
|
||||
if h == "Content-Type"
|
||||
&& let mail_builder::headers::HeaderType::ContentType(ct) = v
|
||||
{
|
||||
let mut ct_new = ct.clone();
|
||||
ct_new = ct_new.attribute("protected-headers", "v1");
|
||||
if use_std_header_protection {
|
||||
ct_new = ct_new.attribute("hp", "cipher");
|
||||
}
|
||||
*ct = ct_new;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Disable compression for SecureJoin to ensure
|
||||
// there are no compression side channels
|
||||
// leaking information about the tokens.
|
||||
@@ -1330,8 +1181,9 @@ impl MimeFactory {
|
||||
}
|
||||
|
||||
let encrypted = if let Some(shared_secret) = shared_secret {
|
||||
let sign = true;
|
||||
encrypt_helper
|
||||
.encrypt_symmetrically(context, &shared_secret, message, compress)
|
||||
.encrypt_symmetrically(context, &shared_secret, message, compress, sign)
|
||||
.await?
|
||||
} else {
|
||||
// Asymmetric encryption
|
||||
@@ -1365,35 +1217,7 @@ impl MimeFactory {
|
||||
.await?
|
||||
};
|
||||
|
||||
// XXX: additional newline is needed
|
||||
// to pass filtermail at
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
|
||||
let encrypted = encrypted + "\n";
|
||||
|
||||
// Set the appropriate Content-Type for the outer message
|
||||
MimePart::new(
|
||||
"multipart/encrypted; protocol=\"application/pgp-encrypted\"",
|
||||
vec![
|
||||
// Autocrypt part 1
|
||||
MimePart::new("application/pgp-encrypted", "Version: 1\r\n").header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("PGP/MIME version identification"),
|
||||
),
|
||||
// Autocrypt part 2
|
||||
MimePart::new(
|
||||
"application/octet-stream; name=\"encrypted.asc\"",
|
||||
encrypted,
|
||||
)
|
||||
.header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("OpenPGP encrypted message"),
|
||||
)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
mail_builder::headers::raw::Raw::new("inline; filename=\"encrypted.asc\";"),
|
||||
),
|
||||
],
|
||||
)
|
||||
wrap_encrypted_part(encrypted)
|
||||
} else if matches!(self.loaded, Loaded::Mdn { .. }) {
|
||||
// Never add outer multipart/mixed wrapper to MDN
|
||||
// as multipart/report Content-Type is used to recognize MDNs
|
||||
@@ -1460,22 +1284,12 @@ impl MimeFactory {
|
||||
}
|
||||
};
|
||||
|
||||
// Store the unprotected headers on the outer message.
|
||||
let outer_message = unprotected_headers
|
||||
.into_iter()
|
||||
.fold(outer_message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
let MimeFactory {
|
||||
last_added_location_id,
|
||||
..
|
||||
} = self;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let cursor = Cursor::new(&mut buffer);
|
||||
outer_message.clone().write_part(cursor).ok();
|
||||
let message = String::from_utf8_lossy(&buffer).to_string();
|
||||
let message = render_outer_message(unprotected_headers, outer_message);
|
||||
|
||||
Ok(RenderedEmail {
|
||||
message,
|
||||
@@ -1615,9 +1429,9 @@ impl MimeFactory {
|
||||
.await?
|
||||
.unwrap_or_default()
|
||||
{
|
||||
placeholdertext = Some("I left the group.".to_string());
|
||||
placeholdertext = Some(format!("{email_to_remove} left the group."));
|
||||
} else {
|
||||
placeholdertext = Some(format!("I removed member {email_to_remove}."));
|
||||
placeholdertext = Some(format!("Member {email_to_remove} was removed."));
|
||||
};
|
||||
|
||||
if !email_to_remove.is_empty() {
|
||||
@@ -1640,7 +1454,7 @@ impl MimeFactory {
|
||||
let email_to_add = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
let fingerprint_to_add = msg.param.get(Param::Arg4).unwrap_or_default();
|
||||
|
||||
placeholdertext = Some(format!("I added member {email_to_add}."));
|
||||
placeholdertext = Some(format!("Member {email_to_add} was added."));
|
||||
|
||||
if !email_to_add.is_empty() {
|
||||
headers.push((
|
||||
@@ -1665,6 +1479,7 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
SystemMessage::GroupNameChanged => {
|
||||
placeholdertext = Some("Chat name changed.".to_string());
|
||||
let old_name = msg.param.get(Param::Arg).unwrap_or_default().to_string();
|
||||
headers.push((
|
||||
"Chat-Group-Name-Changed",
|
||||
@@ -1672,12 +1487,16 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
SystemMessage::GroupDescriptionChanged => {
|
||||
placeholdertext = Some(
|
||||
"[Chat description changed. To see this and other new features, please update the app]".to_string(),
|
||||
);
|
||||
headers.push((
|
||||
"Chat-Group-Description-Changed",
|
||||
mail_builder::headers::text::Text::new("").into(),
|
||||
));
|
||||
}
|
||||
SystemMessage::GroupImageChanged => {
|
||||
placeholdertext = Some("Chat image changed.".to_string());
|
||||
headers.push((
|
||||
"Chat-Content",
|
||||
mail_builder::headers::text::Text::new("group-avatar-changed").into(),
|
||||
@@ -1689,7 +1508,24 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
SystemMessage::Unknown => {}
|
||||
SystemMessage::AutocryptSetupMessage => {}
|
||||
SystemMessage::SecurejoinMessage => {}
|
||||
SystemMessage::LocationStreamingEnabled => {}
|
||||
SystemMessage::LocationOnly => {}
|
||||
SystemMessage::EphemeralTimerChanged => {}
|
||||
SystemMessage::ChatProtectionEnabled => {}
|
||||
SystemMessage::ChatProtectionDisabled => {}
|
||||
SystemMessage::InvalidUnencryptedMail => {}
|
||||
SystemMessage::SecurejoinWait => {}
|
||||
SystemMessage::SecurejoinWaitTimeout => {}
|
||||
SystemMessage::MultiDeviceSync => {}
|
||||
SystemMessage::WebxdcStatusUpdate => {}
|
||||
SystemMessage::WebxdcInfoMessage => {}
|
||||
SystemMessage::IrohNodeAddr => {}
|
||||
SystemMessage::ChatE2ee => {}
|
||||
SystemMessage::CallAccepted => {}
|
||||
SystemMessage::CallEnded => {}
|
||||
}
|
||||
|
||||
if command == SystemMessage::GroupDescriptionChanged
|
||||
@@ -1730,13 +1566,10 @@ impl MimeFactory {
|
||||
| SystemMessage::MultiDeviceSync
|
||||
| SystemMessage::WebxdcStatusUpdate => {
|
||||
// This should prevent automatic replies,
|
||||
// such as non-delivery reports.
|
||||
// such as non-delivery reports,
|
||||
// if the message is unencrypted.
|
||||
//
|
||||
// See <https://tools.ietf.org/html/rfc3834>
|
||||
//
|
||||
// Adding this header without encryption leaks some
|
||||
// information about the message contents, but it can
|
||||
// already be easily guessed from message timing and size.
|
||||
headers.push((
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-generated").into(),
|
||||
@@ -2137,6 +1970,258 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the unprotected headers on the outer message, and renders it.
|
||||
pub(crate) fn render_outer_message(
|
||||
unprotected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
outer_message: MimePart<'static>,
|
||||
) -> String {
|
||||
let outer_message = unprotected_headers
|
||||
.into_iter()
|
||||
.fold(outer_message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let cursor = Cursor::new(&mut buffer);
|
||||
outer_message.clone().write_part(cursor).ok();
|
||||
String::from_utf8_lossy(&buffer).to_string()
|
||||
}
|
||||
|
||||
/// Takes the encrypted part, wraps it in a MimePart,
|
||||
/// and sets the appropriate Content-Type for the outer message
|
||||
pub(crate) fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> {
|
||||
// XXX: additional newline is needed
|
||||
// to pass filtermail at
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
|
||||
let encrypted = encrypted + "\n";
|
||||
|
||||
MimePart::new(
|
||||
"multipart/encrypted; protocol=\"application/pgp-encrypted\"",
|
||||
vec![
|
||||
// Autocrypt part 1
|
||||
MimePart::new("application/pgp-encrypted", "Version: 1\r\n").header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("PGP/MIME version identification"),
|
||||
),
|
||||
// Autocrypt part 2
|
||||
MimePart::new(
|
||||
"application/octet-stream; name=\"encrypted.asc\"",
|
||||
encrypted,
|
||||
)
|
||||
.header(
|
||||
"Content-Description",
|
||||
mail_builder::headers::raw::Raw::new("OpenPGP encrypted message"),
|
||||
)
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
mail_builder::headers::raw::Raw::new("inline; filename=\"encrypted.asc\";"),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn add_headers_to_encrypted_part(
|
||||
message: MimePart<'static>,
|
||||
unprotected_headers: &[(&'static str, HeaderType<'static>)],
|
||||
hidden_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
protected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
use_std_header_protection: bool,
|
||||
) -> MimePart<'static> {
|
||||
// Store protected headers in the inner message.
|
||||
let message = protected_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
// Add hidden headers to encrypted payload.
|
||||
let mut message: MimePart<'static> = hidden_headers
|
||||
.into_iter()
|
||||
.fold(message, |message, (header, value)| {
|
||||
message.header(header, value)
|
||||
});
|
||||
|
||||
if use_std_header_protection {
|
||||
message = unprotected_headers
|
||||
.iter()
|
||||
// Structural headers shouldn't be added as "HP-Outer". They are defined in
|
||||
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
|
||||
.filter(|(name, _)| {
|
||||
!(name.eq_ignore_ascii_case("mime-version")
|
||||
|| name.eq_ignore_ascii_case("content-type")
|
||||
|| name.eq_ignore_ascii_case("content-transfer-encoding")
|
||||
|| name.eq_ignore_ascii_case("content-disposition"))
|
||||
})
|
||||
.fold(message, |message, (name, value)| {
|
||||
message.header(format!("HP-Outer: {name}"), value.clone())
|
||||
});
|
||||
}
|
||||
|
||||
// Set the appropriate Content-Type for the inner message
|
||||
for (h, v) in &mut message.headers {
|
||||
if h == "Content-Type"
|
||||
&& let mail_builder::headers::HeaderType::ContentType(ct) = v
|
||||
{
|
||||
let mut ct_new = ct.clone();
|
||||
ct_new = ct_new.attribute("protected-headers", "v1");
|
||||
if use_std_header_protection {
|
||||
ct_new = ct_new.attribute("hp", "cipher");
|
||||
}
|
||||
*ct = ct_new;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
message
|
||||
}
|
||||
|
||||
struct HeadersByConfidentiality {
|
||||
/// Headers that must go into IMF header section.
|
||||
///
|
||||
/// These are standard headers such as Date, In-Reply-To, References, which cannot be placed
|
||||
/// anywhere else according to the standard. Placing headers here also allows them to be fetched
|
||||
/// individually over IMAP without downloading the message body. This is why Chat-Version is
|
||||
/// placed here.
|
||||
unprotected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
|
||||
/// Headers that MUST NOT (only) go into IMF header section:
|
||||
/// - Large headers which may hit the header section size limit on the server, such as
|
||||
/// Chat-User-Avatar with a base64-encoded image inside.
|
||||
/// - Headers duplicated here that servers mess up with in the IMF header section, like
|
||||
/// Message-ID.
|
||||
/// - Nonstandard headers that should be DKIM-protected because e.g. OpenDKIM only signs
|
||||
/// known headers.
|
||||
///
|
||||
/// The header should be hidden from MTA
|
||||
/// by moving it either into protected part
|
||||
/// in case of encrypted mails
|
||||
/// or unprotected MIME preamble in case of unencrypted mails.
|
||||
hidden_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
|
||||
/// Opportunistically protected headers.
|
||||
///
|
||||
/// These headers are placed into encrypted part *if* the message is encrypted. Place headers
|
||||
/// which are not needed before decryption (e.g. Chat-Group-Name) or are not interesting if the
|
||||
/// message cannot be decrypted (e.g. Chat-Disposition-Notification-To) here.
|
||||
///
|
||||
/// If the message is not encrypted, these headers are placed into IMF header section, so make
|
||||
/// sure that the message will be encrypted if you place any sensitive information here.
|
||||
protected_headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
}
|
||||
|
||||
/// Split headers based on header confidentiality policy.
|
||||
/// See [`HeadersByConfidentiality`] for more info.
|
||||
fn group_headers_by_confidentiality(
|
||||
headers: Vec<(&'static str, HeaderType<'static>)>,
|
||||
from_addr: &str,
|
||||
timestamp: i64,
|
||||
is_encrypted: bool,
|
||||
is_securejoin_message: bool,
|
||||
) -> HeadersByConfidentiality {
|
||||
let mut unprotected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
let mut hidden_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
let mut protected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
|
||||
|
||||
// MIME header <https://datatracker.ietf.org/doc/html/rfc2045>.
|
||||
unprotected_headers.push((
|
||||
"MIME-Version",
|
||||
mail_builder::headers::raw::Raw::new("1.0").into(),
|
||||
));
|
||||
|
||||
for header @ (original_header_name, _header_value) in &headers {
|
||||
let header_name = original_header_name.to_lowercase();
|
||||
if header_name == "message-id" {
|
||||
unprotected_headers.push(header.clone());
|
||||
hidden_headers.push(header.clone());
|
||||
} else if is_hidden(&header_name) {
|
||||
hidden_headers.push(header.clone());
|
||||
} else if header_name == "from" {
|
||||
// Unencrypted securejoin messages should _not_ include the display name:
|
||||
if is_encrypted || !is_securejoin_message {
|
||||
protected_headers.push(header.clone());
|
||||
}
|
||||
|
||||
unprotected_headers.push((
|
||||
original_header_name,
|
||||
Address::new_address(None::<&'static str>, from_addr.to_string()).into(),
|
||||
));
|
||||
} else if header_name == "to" {
|
||||
protected_headers.push(header.clone());
|
||||
if is_encrypted {
|
||||
unprotected_headers.push(("To", hidden_recipients().into()));
|
||||
} else {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
} else if header_name == "chat-broadcast-secret" {
|
||||
if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
}
|
||||
} else if is_encrypted && header_name == "date" {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
// Randomized date goes to unprotected header.
|
||||
//
|
||||
// We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000"
|
||||
// or omit the header because GMX then fails with
|
||||
//
|
||||
// host mx00.emig.gmx.net[212.227.15.9] said:
|
||||
// 554-Transaction failed
|
||||
// 554-Reject due to policy restrictions.
|
||||
// 554 For explanation visit https://postmaster.gmx.net/en/case?...
|
||||
// (in reply to end of DATA command)
|
||||
//
|
||||
// and the explanation page says
|
||||
// "The time information deviates too much from the actual time".
|
||||
//
|
||||
// We also limit the range to 6 days (518400 seconds)
|
||||
// because with a larger range we got
|
||||
// error "500 Date header far in the past/future"
|
||||
// which apparently originates from Symantec Messaging Gateway
|
||||
// and means the message has a Date that is more
|
||||
// than 7 days in the past:
|
||||
// <https://github.com/chatmail/core/issues/7466>
|
||||
let timestamp_offset = rand::random_range(0..518400);
|
||||
let protected_timestamp = timestamp.saturating_sub(timestamp_offset);
|
||||
let unprotected_date =
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(protected_timestamp, 0)
|
||||
.unwrap()
|
||||
.to_rfc2822();
|
||||
unprotected_headers.push((
|
||||
"Date",
|
||||
mail_builder::headers::raw::Raw::new(unprotected_date).into(),
|
||||
));
|
||||
} else if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
match header_name.as_str() {
|
||||
"subject" => {
|
||||
unprotected_headers.push((
|
||||
"Subject",
|
||||
mail_builder::headers::raw::Raw::new("[...]").into(),
|
||||
));
|
||||
}
|
||||
"chat-version" | "autocrypt-setup-message" | "chat-is-post-message" => {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
_ => {
|
||||
// Other headers are removed from unprotected part.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Copy the header to the protected headers
|
||||
// in case of signed-only message.
|
||||
// If the message is not signed, this value will not be used.
|
||||
protected_headers.push(header.clone());
|
||||
unprotected_headers.push(header.clone())
|
||||
}
|
||||
}
|
||||
HeadersByConfidentiality {
|
||||
unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
}
|
||||
}
|
||||
|
||||
fn hidden_recipients() -> Address<'static> {
|
||||
Address::new_group(Some("hidden-recipients".to_string()), Vec::new())
|
||||
}
|
||||
@@ -2244,5 +2329,115 @@ fn b_encode(value: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn render_symm_encrypted_securejoin_message(
|
||||
context: &Context,
|
||||
step: &str,
|
||||
rfc724_mid: &str,
|
||||
attach_self_pubkey: bool,
|
||||
auth: &str,
|
||||
shared_secret: &str,
|
||||
) -> Result<String> {
|
||||
info!(context, "Sending secure-join message {step:?}.");
|
||||
|
||||
let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new();
|
||||
|
||||
let from_addr = context.get_primary_self_addr().await?;
|
||||
let from = new_address_with_name("", from_addr.to_string());
|
||||
headers.push(("From", from.into()));
|
||||
|
||||
let to: Vec<Address<'static>> = vec![hidden_recipients()];
|
||||
headers.push((
|
||||
"To",
|
||||
mail_builder::headers::address::Address::new_list(to.clone()).into(),
|
||||
));
|
||||
|
||||
headers.push((
|
||||
"Subject",
|
||||
mail_builder::headers::text::Text::new("Secure-Join".to_string()).into(),
|
||||
));
|
||||
|
||||
let timestamp = create_smeared_timestamp(context);
|
||||
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0)
|
||||
.unwrap()
|
||||
.to_rfc2822();
|
||||
headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into()));
|
||||
|
||||
headers.push((
|
||||
"Message-ID",
|
||||
mail_builder::headers::message_id::MessageId::new(rfc724_mid.to_string()).into(),
|
||||
));
|
||||
|
||||
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
|
||||
if context.get_config_bool(Config::Bot).await? {
|
||||
headers.push((
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
|
||||
));
|
||||
}
|
||||
|
||||
let encrypt_helper = EncryptHelper::new(context).await?;
|
||||
|
||||
if attach_self_pubkey {
|
||||
let aheader = encrypt_helper.get_aheader().to_string();
|
||||
headers.push((
|
||||
"Autocrypt",
|
||||
mail_builder::headers::raw::Raw::new(aheader).into(),
|
||||
));
|
||||
}
|
||||
|
||||
headers.push((
|
||||
"Secure-Join",
|
||||
mail_builder::headers::raw::Raw::new(step.to_string()).into(),
|
||||
));
|
||||
|
||||
headers.push((
|
||||
"Secure-Join-Auth",
|
||||
mail_builder::headers::text::Text::new(auth.to_string()).into(),
|
||||
));
|
||||
|
||||
let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join");
|
||||
|
||||
let is_encrypted = true;
|
||||
let is_securejoin_message = true;
|
||||
let HeadersByConfidentiality {
|
||||
unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
} = group_headers_by_confidentiality(
|
||||
headers,
|
||||
&from_addr,
|
||||
timestamp,
|
||||
is_encrypted,
|
||||
is_securejoin_message,
|
||||
);
|
||||
|
||||
let outer_message = {
|
||||
let use_std_header_protection = true;
|
||||
let message = add_headers_to_encrypted_part(
|
||||
message,
|
||||
&unprotected_headers,
|
||||
hidden_headers,
|
||||
protected_headers,
|
||||
use_std_header_protection,
|
||||
);
|
||||
|
||||
// Disable compression for SecureJoin to ensure
|
||||
// there are no compression side channels
|
||||
// leaking information about the tokens.
|
||||
let compress = false;
|
||||
// Only sign the message if we attach the pubkey.
|
||||
let sign = attach_self_pubkey;
|
||||
let encrypted = encrypt_helper
|
||||
.encrypt_symmetrically(context, shared_secret, message, compress, sign)
|
||||
.await?;
|
||||
|
||||
wrap_encrypted_part(encrypted)
|
||||
};
|
||||
|
||||
let message = render_outer_message(unprotected_headers, outer_message);
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mimefactory_tests;
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::Path;
|
||||
use std::str;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use deltachat_contact_tools::{addr_cmp, addr_normalize, sanitize_bidi_characters};
|
||||
use deltachat_derive::{FromSql, ToSql};
|
||||
use format_flowed::unformat_flowed;
|
||||
@@ -19,14 +19,14 @@ use crate::blob::BlobObject;
|
||||
use crate::chat::ChatId;
|
||||
use crate::config::Config;
|
||||
use crate::constants;
|
||||
use crate::contact::ContactId;
|
||||
use crate::contact::{ContactId, import_public_key};
|
||||
use crate::context::Context;
|
||||
use crate::decrypt::{try_decrypt, validate_detached_signature};
|
||||
use crate::decrypt::{self, validate_detached_signature};
|
||||
use crate::dehtml::dehtml;
|
||||
use crate::download::PostMsgMetadata;
|
||||
use crate::events::EventType;
|
||||
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
||||
use crate::key::{self, DcKey, Fingerprint, SignedPublicKey, load_self_secret_keyring};
|
||||
use crate::key::{self, DcKey, Fingerprint, SignedPublicKey};
|
||||
use crate::log::warn;
|
||||
use crate::message::{self, Message, MsgId, Viewtype, get_vcard_summary, set_msg_failed};
|
||||
use crate::param::{Param, Params};
|
||||
@@ -359,10 +359,10 @@ impl MimeMessage {
|
||||
|
||||
// Remove headers that are allowed _only_ in the encrypted+signed part. It's ok to leave
|
||||
// them in signed-only emails, but has no value currently.
|
||||
Self::remove_secured_headers(&mut headers, &mut headers_removed);
|
||||
let encrypted = false;
|
||||
Self::remove_secured_headers(&mut headers, &mut headers_removed, encrypted);
|
||||
|
||||
let mut from = from.context("No from in message")?;
|
||||
let private_keyring = load_self_secret_keyring(context).await?;
|
||||
|
||||
let dkim_results = handle_authres(context, &mail, &from.addr).await?;
|
||||
|
||||
@@ -386,57 +386,53 @@ impl MimeMessage {
|
||||
|
||||
let mail_raw; // Memory location for a possible decrypted message.
|
||||
let decrypted_msg; // Decrypted signed OpenPGP message.
|
||||
let secrets: Vec<String> = context
|
||||
.sql
|
||||
.query_map_vec("SELECT secret FROM broadcast_secrets", (), |row| {
|
||||
let secret: String = row.get(0)?;
|
||||
Ok(secret)
|
||||
})
|
||||
.await?;
|
||||
let expected_sender_fingerprint: Option<String>;
|
||||
|
||||
let (mail, is_encrypted) =
|
||||
match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring, &secrets)) {
|
||||
Ok(Some(mut msg)) => {
|
||||
mail_raw = msg.as_data_vec().unwrap_or_default();
|
||||
let (mail, is_encrypted) = match decrypt::decrypt(context, &mail).await {
|
||||
Ok(Some((mut msg, expected_sender_fp))) => {
|
||||
mail_raw = msg.as_data_vec().unwrap_or_default();
|
||||
|
||||
let decrypted_mail = mailparse::parse_mail(&mail_raw)?;
|
||||
if std::env::var(crate::DCC_MIME_DEBUG).is_ok() {
|
||||
info!(
|
||||
context,
|
||||
"decrypted message mime-body:\n{}",
|
||||
String::from_utf8_lossy(&mail_raw),
|
||||
);
|
||||
}
|
||||
|
||||
decrypted_msg = Some(msg);
|
||||
|
||||
timestamp_sent = Self::get_timestamp_sent(
|
||||
&decrypted_mail.headers,
|
||||
timestamp_sent,
|
||||
timestamp_rcvd,
|
||||
let decrypted_mail = mailparse::parse_mail(&mail_raw)?;
|
||||
if std::env::var(crate::DCC_MIME_DEBUG).is_ok() {
|
||||
info!(
|
||||
context,
|
||||
"decrypted message mime-body:\n{}",
|
||||
String::from_utf8_lossy(&mail_raw),
|
||||
);
|
||||
}
|
||||
|
||||
let protected_aheader_values = decrypted_mail
|
||||
.headers
|
||||
.get_all_values(HeaderDef::Autocrypt.into());
|
||||
if !protected_aheader_values.is_empty() {
|
||||
aheader_values = protected_aheader_values;
|
||||
}
|
||||
decrypted_msg = Some(msg);
|
||||
|
||||
(Ok(decrypted_mail), true)
|
||||
timestamp_sent = Self::get_timestamp_sent(
|
||||
&decrypted_mail.headers,
|
||||
timestamp_sent,
|
||||
timestamp_rcvd,
|
||||
);
|
||||
|
||||
let protected_aheader_values = decrypted_mail
|
||||
.headers
|
||||
.get_all_values(HeaderDef::Autocrypt.into());
|
||||
if !protected_aheader_values.is_empty() {
|
||||
aheader_values = protected_aheader_values;
|
||||
}
|
||||
Ok(None) => {
|
||||
mail_raw = Vec::new();
|
||||
decrypted_msg = None;
|
||||
(Ok(mail), false)
|
||||
}
|
||||
Err(err) => {
|
||||
mail_raw = Vec::new();
|
||||
decrypted_msg = None;
|
||||
warn!(context, "decryption failed: {:#}", err);
|
||||
(Err(err), false)
|
||||
}
|
||||
};
|
||||
|
||||
expected_sender_fingerprint = expected_sender_fp;
|
||||
(Ok(decrypted_mail), true)
|
||||
}
|
||||
Ok(None) => {
|
||||
mail_raw = Vec::new();
|
||||
decrypted_msg = None;
|
||||
expected_sender_fingerprint = None;
|
||||
(Ok(mail), false)
|
||||
}
|
||||
Err(err) => {
|
||||
mail_raw = Vec::new();
|
||||
decrypted_msg = None;
|
||||
expected_sender_fingerprint = None;
|
||||
warn!(context, "decryption failed: {:#}", err);
|
||||
(Err(err), false)
|
||||
}
|
||||
};
|
||||
|
||||
let mut autocrypt_header = None;
|
||||
if incoming {
|
||||
@@ -462,22 +458,9 @@ impl MimeMessage {
|
||||
|
||||
let autocrypt_fingerprint = if let Some(autocrypt_header) = &autocrypt_header {
|
||||
let fingerprint = autocrypt_header.public_key.dc_fingerprint().hex();
|
||||
let inserted = context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO public_keys (fingerprint, public_key)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (fingerprint)
|
||||
DO NOTHING",
|
||||
(&fingerprint, autocrypt_header.public_key.to_bytes()),
|
||||
)
|
||||
.await?;
|
||||
if inserted > 0 {
|
||||
info!(
|
||||
context,
|
||||
"Saved key with fingerprint {fingerprint} from the Autocrypt header"
|
||||
);
|
||||
}
|
||||
import_public_key(context, &autocrypt_header.public_key)
|
||||
.await
|
||||
.context("Failed to import public key from the Autocrypt header")?;
|
||||
Some(fingerprint)
|
||||
} else {
|
||||
None
|
||||
@@ -546,6 +529,22 @@ impl MimeMessage {
|
||||
signatures.extend(signatures_detached);
|
||||
content
|
||||
});
|
||||
|
||||
if let Some(expected_sender_fingerprint) = expected_sender_fingerprint {
|
||||
ensure!(
|
||||
!signatures.is_empty(),
|
||||
"Unsigned message is not allowed to be encrypted with this shared secret"
|
||||
);
|
||||
ensure!(
|
||||
signatures.len() == 1,
|
||||
"Too many signatures on symm-encrypted message"
|
||||
);
|
||||
ensure!(
|
||||
signatures.contains_key(&expected_sender_fingerprint.parse()?),
|
||||
"This sender is not allowed to encrypt with this secret key"
|
||||
);
|
||||
}
|
||||
|
||||
if let (Ok(mail), true) = (mail, is_encrypted) {
|
||||
if !signatures.is_empty() {
|
||||
// Unsigned "Subject" mustn't be prepended to messages shown as encrypted
|
||||
@@ -609,7 +608,7 @@ impl MimeMessage {
|
||||
}
|
||||
}
|
||||
if signatures.is_empty() {
|
||||
Self::remove_secured_headers(&mut headers, &mut headers_removed);
|
||||
Self::remove_secured_headers(&mut headers, &mut headers_removed, is_encrypted);
|
||||
}
|
||||
if !is_encrypted {
|
||||
signatures.clear();
|
||||
@@ -1647,24 +1646,12 @@ impl MimeMessage {
|
||||
}
|
||||
Ok(key) => key,
|
||||
};
|
||||
if let Err(err) = key.verify_bindings() {
|
||||
warn!(context, "Attached PGP key verification failed: {err:#}.");
|
||||
if let Err(err) = import_public_key(context, &key).await {
|
||||
warn!(context, "Attached PGP key import failed: {err:#}.");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let fingerprint = key.dc_fingerprint().hex();
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO public_keys (fingerprint, public_key)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (fingerprint)
|
||||
DO NOTHING",
|
||||
(&fingerprint, key.to_bytes()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(context, "Imported PGP key {fingerprint} from attachment.");
|
||||
info!(context, "Imported PGP key from attachment.");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -1722,20 +1709,37 @@ impl MimeMessage {
|
||||
.and_then(|msgid| parse_message_id(msgid).ok())
|
||||
}
|
||||
|
||||
/// Remove headers that are not allowed in unsigned / unencrypted messages.
|
||||
///
|
||||
/// Pass `encrypted=true` parameter for an encrypted, but unsigned message.
|
||||
/// Pass `encrypted=false` parameter for an unencrypted message.
|
||||
/// Don't call this function if the message was encrypted and signed.
|
||||
fn remove_secured_headers(
|
||||
headers: &mut HashMap<String, String>,
|
||||
removed: &mut HashSet<String>,
|
||||
encrypted: bool,
|
||||
) {
|
||||
remove_header(headers, "secure-join-fingerprint", removed);
|
||||
remove_header(headers, "secure-join-auth", removed);
|
||||
remove_header(headers, "chat-verified", removed);
|
||||
remove_header(headers, "autocrypt-gossip", removed);
|
||||
|
||||
// Secure-Join is secured unless it is an initial "vc-request"/"vg-request".
|
||||
if let Some(secure_join) = remove_header(headers, "secure-join", removed)
|
||||
&& (secure_join == "vc-request" || secure_join == "vg-request")
|
||||
{
|
||||
headers.insert("secure-join".to_string(), secure_join);
|
||||
if headers.get("secure-join") == Some(&"vc-request-pubkey".to_string()) && encrypted {
|
||||
// vc-request-pubkey message is encrypted, but unsigned,
|
||||
// and contains a Secure-Join-Auth header.
|
||||
//
|
||||
// It is unsigned in order not to leak Bob's identity to a server operator
|
||||
// that scraped the AUTH token somewhere from the web,
|
||||
// and because Alice anyways couldn't verify his signature at this step,
|
||||
// because she doesn't know his public key yet.
|
||||
} else {
|
||||
remove_header(headers, "secure-join-auth", removed);
|
||||
|
||||
// Secure-Join is secured unless it is an initial "vc-request"/"vg-request".
|
||||
if let Some(secure_join) = remove_header(headers, "secure-join", removed)
|
||||
&& (secure_join == "vc-request" || secure_join == "vg-request")
|
||||
{
|
||||
headers.insert("secure-join".to_string(), secure_join);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2156,17 +2160,9 @@ async fn parse_gossip_headers(
|
||||
continue;
|
||||
}
|
||||
|
||||
let fingerprint = header.public_key.dc_fingerprint().hex();
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO public_keys (fingerprint, public_key)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (fingerprint)
|
||||
DO NOTHING",
|
||||
(&fingerprint, header.public_key.to_bytes()),
|
||||
)
|
||||
.await?;
|
||||
import_public_key(context, &header.public_key)
|
||||
.await
|
||||
.context("Failed to import Autocrypt-Gossip key")?;
|
||||
|
||||
let gossiped_key = GossipedKey {
|
||||
public_key: header.public_key,
|
||||
@@ -2604,3 +2600,5 @@ async fn handle_ndn(
|
||||
|
||||
#[cfg(test)]
|
||||
mod mimeparser_tests;
|
||||
#[cfg(test)]
|
||||
mod shared_secret_decryption_tests;
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
key,
|
||||
message::{MessageState, MessengerMessage},
|
||||
receive_imf::receive_imf,
|
||||
securejoin::QrInvite,
|
||||
test_utils::{TestContext, TestContextManager},
|
||||
tools::time,
|
||||
};
|
||||
@@ -2156,3 +2157,27 @@ Third alternative.
|
||||
assert_eq!(message.parts[0].typ, Viewtype::Text);
|
||||
assert_eq!(message.parts[0].msg, "Third alternative.");
|
||||
}
|
||||
|
||||
/// Tests that loading a bobstate from an old version of Delta Chat
|
||||
/// (that doesn't have the is_v3 attribute)
|
||||
/// doesn't fail
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_load_shared_secrets_with_legacy_state() -> Result<()> {
|
||||
let alice = &TestContext::new_alice().await;
|
||||
|
||||
alice.sql.execute(
|
||||
r#"INSERT INTO bobstate (invite, next_step, chat_id)
|
||||
VALUES ('{"Contact":{"contact_id":10,"fingerprint":[111,111,111,11,111,11,111,111,111,11,11,111,11,111,111,111,111,111,11,111],"invitenumber":"xxxxxxxxxxxxxxxxxxxxxxxx","authcode":"yyyyyyyyyyyyyyyyyyyyyyyy"}}', 0, 10)"#,
|
||||
()
|
||||
).await?;
|
||||
|
||||
let qr: QrInvite = alice
|
||||
.sql
|
||||
.query_get_value("SELECT invite FROM bobstate", ())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(qr.is_v3(), false);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
258
src/mimeparser/shared_secret_decryption_tests.rs
Normal file
258
src/mimeparser/shared_secret_decryption_tests.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use super::*;
|
||||
use crate::chat::{create_broadcast, load_broadcast_secret};
|
||||
use crate::constants::DC_CHAT_ID_TRASH;
|
||||
use crate::key::{load_self_secret_key, self_fingerprint};
|
||||
use crate::pgp;
|
||||
use crate::qr::{Qr, check_qr};
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::securejoin::{get_securejoin_qr, join_securejoin};
|
||||
use crate::test_utils::{TestContext, TestContextManager};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Tests that the following attack isn't possible:
|
||||
///
|
||||
/// Eve is subscribed to a channel and wants to know whether Alice is also subscribed to it.
|
||||
/// To achieve this, Eve sends a message to Alice
|
||||
/// encrypted with the symmetric secret of this broadcast channel.
|
||||
///
|
||||
/// If Alice sends an answer (or read receipt),
|
||||
/// then Eve knows that Alice is in the broadcast channel.
|
||||
///
|
||||
/// A similar attack would be possible with auth tokens
|
||||
/// that are also used to symmetrically encrypt messages.
|
||||
///
|
||||
/// To defeat this, a message that was unexpectedly
|
||||
/// encrypted with a symmetric secret must be dropped.
|
||||
async fn test_shared_secret_decryption_ex(
|
||||
recipient_ctx: &TestContext,
|
||||
from_addr: &str,
|
||||
secret_for_encryption: &str,
|
||||
signer_ctx: Option<&TestContext>,
|
||||
expected_error: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let plain_body = "Hello, this is a secure message.";
|
||||
let plain_text = format!("Content-Type: text/plain; charset=utf-8\r\n\r\n{plain_body}");
|
||||
let previous_highest_msg_id = get_highest_msg_id(recipient_ctx).await;
|
||||
|
||||
let signer_key = if let Some(signer_ctx) = signer_ctx {
|
||||
Some(load_self_secret_key(signer_ctx).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(signer_ctx) = signer_ctx {
|
||||
// The recipient needs to know the signer's pubkey
|
||||
// in order to be able to validate the pubkey:
|
||||
recipient_ctx.add_or_lookup_contact(signer_ctx).await;
|
||||
}
|
||||
|
||||
let encrypted_msg = pgp::symm_encrypt_message(
|
||||
plain_text.as_bytes().to_vec(),
|
||||
signer_key,
|
||||
secret_for_encryption,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let boundary = "boundary123";
|
||||
let rcvd_mail = format!(
|
||||
"Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"{boundary}\"\n\
|
||||
From: {from}\n\
|
||||
To: \"hidden-recipients\": ;\n\
|
||||
Subject: [...]\n\
|
||||
MIME-Version: 1.0\n\
|
||||
Message-ID: <12345@example.org>\n\
|
||||
\n\
|
||||
--{boundary}\n\
|
||||
Content-Type: application/pgp-encrypted\n\
|
||||
\n\
|
||||
Version: 1\n\
|
||||
\n\
|
||||
--{boundary}\n\
|
||||
Content-Type: application/octet-stream; name=\"encrypted.asc\"\n\
|
||||
Content-Disposition: inline; filename=\"encrypted.asc\"\n\
|
||||
\n\
|
||||
{encrypted_msg}\n\
|
||||
--{boundary}--\n",
|
||||
from = from_addr,
|
||||
boundary = boundary,
|
||||
encrypted_msg = encrypted_msg
|
||||
);
|
||||
|
||||
let rcvd = receive_imf(recipient_ctx, rcvd_mail.as_bytes(), false)
|
||||
.await
|
||||
.expect("If receive_imf() adds an error here, then Bob may be notified about the error and tell the attacker, leaking that he knows the secret")
|
||||
.expect("A trashed message should be created, otherwise we'll unnecessarily download it again");
|
||||
|
||||
if let Some(error_pattern) = expected_error {
|
||||
assert!(rcvd.chat_id == DC_CHAT_ID_TRASH);
|
||||
assert_eq!(
|
||||
previous_highest_msg_id,
|
||||
get_highest_msg_id(recipient_ctx).await,
|
||||
"receive_imf() must not add any message. Otherwise, Bob may send something about an error to the attacker, leaking that he knows the secret"
|
||||
);
|
||||
let EventType::Warning(warning) = recipient_ctx
|
||||
.evtracker
|
||||
.get_matching(|ev| matches!(ev, EventType::Warning(_)))
|
||||
.await
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(warning.contains(error_pattern), "Wrong warning: {warning}");
|
||||
} else {
|
||||
let msg = recipient_ctx.get_last_msg().await;
|
||||
assert_eq!(&[msg.id], rcvd.msg_ids.as_slice());
|
||||
assert_eq!(msg.text, plain_body);
|
||||
assert_eq!(rcvd.chat_id.is_special(), false);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_highest_msg_id(context: &Context) -> MsgId {
|
||||
context
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT MAX(id) FROM msgs WHERE chat_id!=?",
|
||||
(DC_CHAT_ID_TRASH,),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_security_attacker_signature() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let charlie = &tcm.charlie().await; // Attacker
|
||||
|
||||
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
|
||||
|
||||
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
|
||||
|
||||
test_shared_secret_decryption_ex(
|
||||
bob,
|
||||
&charlie_addr,
|
||||
&secret,
|
||||
Some(charlie),
|
||||
Some("This sender is not allowed to encrypt with this secret key"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_security_no_signature() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
|
||||
|
||||
test_shared_secret_decryption_ex(
|
||||
bob,
|
||||
"attacker@example.org",
|
||||
&secret,
|
||||
None,
|
||||
Some("Unsigned message is not allowed to be encrypted with this shared secret"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_security_happy_path() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
|
||||
|
||||
let alice_addr = alice
|
||||
.get_config(crate::config::Config::Addr)
|
||||
.await?
|
||||
.unwrap();
|
||||
|
||||
test_shared_secret_decryption_ex(bob, &alice_addr, &secret, Some(alice), None).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_qr_code_security() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let charlie = &tcm.charlie().await; // Attacker
|
||||
|
||||
let qr = get_securejoin_qr(alice, None).await?;
|
||||
let Qr::AskVerifyContact { authcode, .. } = check_qr(bob, &qr).await? else {
|
||||
unreachable!()
|
||||
};
|
||||
// Start a securejoin process, but don't finish it:
|
||||
join_securejoin(bob, &qr).await?;
|
||||
|
||||
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
|
||||
|
||||
let alice_fp = self_fingerprint(alice).await?;
|
||||
let secret_for_encryption = dbg!(format!("securejoin/{alice_fp}/{authcode}"));
|
||||
test_shared_secret_decryption_ex(
|
||||
bob,
|
||||
&charlie_addr,
|
||||
&secret_for_encryption,
|
||||
Some(charlie),
|
||||
Some("This sender is not allowed to encrypt with this secret key"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_qr_code_happy_path() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let qr = get_securejoin_qr(alice, None).await?;
|
||||
let Qr::AskVerifyContact { authcode, .. } = check_qr(bob, &qr).await? else {
|
||||
unreachable!()
|
||||
};
|
||||
// Start a securejoin process, but don't finish it:
|
||||
join_securejoin(bob, &qr).await?;
|
||||
|
||||
let alice_fp = self_fingerprint(alice).await?;
|
||||
let secret_for_encryption = format!("securejoin/{alice_fp}/{authcode}");
|
||||
test_shared_secret_decryption_ex(
|
||||
bob,
|
||||
"alice@example.net",
|
||||
&secret_for_encryption,
|
||||
Some(alice),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Control: Test that the behavior is the same when the shared secret is unknown
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_unknown_secret() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
test_shared_secret_decryption_ex(
|
||||
bob,
|
||||
"alice@example.net",
|
||||
"Some secret unknown to Bob",
|
||||
Some(alice),
|
||||
Some("Could not find symmetric secret for session key"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
473
src/pgp.rs
473
src/pgp.rs
@@ -3,23 +3,25 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::{BufRead, Cursor};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
use anyhow::{Context as _, Result, ensure};
|
||||
use deltachat_contact_tools::{EmailAddress, may_be_valid_addr};
|
||||
use pgp::armor::BlockType;
|
||||
use pgp::composed::{
|
||||
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, EncryptionCaps,
|
||||
KeyType as PgpKeyType, Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey,
|
||||
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig, TheRing,
|
||||
ArmorOptions, Deserializable, DetachedSignature, EncryptionCaps, KeyType as PgpKeyType,
|
||||
Message, MessageBuilder, SecretKeyParamsBuilder, SignedKeyDetails, SignedPublicKey,
|
||||
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig,
|
||||
};
|
||||
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::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::packet::{Signature, SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::types::{
|
||||
CompressionAlgorithm, KeyDetails, KeyVersion, Password, SigningKey as _, StringToKey,
|
||||
CompressionAlgorithm, Imprint, KeyDetails, KeyVersion, Password, SignedUser, SigningKey as _,
|
||||
StringToKey,
|
||||
};
|
||||
use rand_old::{Rng as _, thread_rng};
|
||||
use sha2::Sha256;
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
use crate::key::{DcKey, Fingerprint};
|
||||
@@ -63,34 +65,11 @@ pub fn split_armored_data(buf: &[u8]) -> Result<(BlockType, BTreeMap<String, Str
|
||||
Ok((typ, headers, bytes))
|
||||
}
|
||||
|
||||
/// A PGP keypair.
|
||||
///
|
||||
/// This has it's own struct to be able to keep the public and secret
|
||||
/// keys together as they are one unit.
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct KeyPair {
|
||||
/// Public key.
|
||||
pub public: SignedPublicKey,
|
||||
|
||||
/// Secret key.
|
||||
pub secret: SignedSecretKey,
|
||||
}
|
||||
|
||||
impl KeyPair {
|
||||
/// Creates new keypair from a secret key.
|
||||
///
|
||||
/// Public key is split off the secret key.
|
||||
pub fn new(secret: SignedSecretKey) -> Result<Self> {
|
||||
let public = secret.to_public_key();
|
||||
Ok(Self { public, secret })
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new key pair.
|
||||
///
|
||||
/// Both secret and public key consist of signing primary key and encryption subkey
|
||||
/// as [described in the Autocrypt standard](https://autocrypt.org/level1.html#openpgp-based-key-data).
|
||||
pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
|
||||
pub(crate) fn create_keypair(addr: EmailAddress) -> Result<SignedSecretKey> {
|
||||
let signing_key_type = PgpKeyType::Ed25519Legacy;
|
||||
let encryption_key_type = PgpKeyType::ECDH(ECCCurve::Curve25519);
|
||||
|
||||
@@ -99,6 +78,7 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
|
||||
.key_type(signing_key_type)
|
||||
.can_certify(true)
|
||||
.can_sign(true)
|
||||
.feature_seipd_v2(true)
|
||||
.primary_user_id(user_id)
|
||||
.passphrase(None)
|
||||
.preferred_symmetric_algorithms(smallvec![
|
||||
@@ -135,12 +115,7 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
|
||||
.verify_bindings()
|
||||
.context("Invalid secret key generated")?;
|
||||
|
||||
let key_pair = KeyPair::new(secret_key)?;
|
||||
key_pair
|
||||
.public
|
||||
.verify_bindings()
|
||||
.context("Invalid public key generated")?;
|
||||
Ok(key_pair)
|
||||
Ok(secret_key)
|
||||
}
|
||||
|
||||
/// Selects a subkey of the public key to use for encryption.
|
||||
@@ -320,95 +295,6 @@ pub fn pk_calc_signature(
|
||||
Ok(sig.to_armored_string(ArmorOptions::default())?)
|
||||
}
|
||||
|
||||
/// Decrypts the message:
|
||||
/// - with keys from the private key keyring (passed in `private_keys_for_decryption`)
|
||||
/// if the message was asymmetrically encrypted,
|
||||
/// - with a shared secret/password (passed in `shared_secrets`),
|
||||
/// if the message was symmetrically encrypted.
|
||||
///
|
||||
/// Returns the decrypted and decompressed message.
|
||||
pub fn decrypt(
|
||||
ctext: Vec<u8>,
|
||||
private_keys_for_decryption: &[SignedSecretKey],
|
||||
mut shared_secrets: &[String],
|
||||
) -> Result<pgp::composed::Message<'static>> {
|
||||
let cursor = Cursor::new(ctext);
|
||||
let (msg, _headers) = Message::from_armor(cursor)?;
|
||||
|
||||
let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect();
|
||||
let empty_pw = Password::empty();
|
||||
|
||||
let decrypt_options = DecryptionOptions::new();
|
||||
let symmetric_encryption_res = check_symmetric_encryption(&msg);
|
||||
if symmetric_encryption_res.is_err() {
|
||||
shared_secrets = &[];
|
||||
}
|
||||
|
||||
// We always try out all passwords here,
|
||||
// but benchmarking (see `benches/decrypting.rs`)
|
||||
// showed that the performance impact is negligible.
|
||||
// We can improve this in the future if necessary.
|
||||
let message_password: Vec<Password> = shared_secrets
|
||||
.iter()
|
||||
.map(|p| Password::from(p.as_str()))
|
||||
.collect();
|
||||
let message_password: Vec<&Password> = message_password.iter().collect();
|
||||
|
||||
let ring = TheRing {
|
||||
secret_keys: skeys,
|
||||
key_passwords: vec![&empty_pw],
|
||||
message_password,
|
||||
session_keys: vec![],
|
||||
decrypt_options,
|
||||
};
|
||||
|
||||
let res = msg.decrypt_the_ring(ring, true);
|
||||
|
||||
let (msg, _ring_result) = match res {
|
||||
Ok(it) => it,
|
||||
Err(err) => {
|
||||
if let Err(reason) = symmetric_encryption_res {
|
||||
bail!("{err:#} (Note: symmetric decryption was not tried: {reason})")
|
||||
} else {
|
||||
bail!("{err:#}");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// remove one layer of compression
|
||||
let msg = msg.decompress()?;
|
||||
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
|
||||
/// and Err with a reason if symmetric decryption should not be tried.
|
||||
///
|
||||
/// A DOS attacker could send a message with a lot of encrypted session keys,
|
||||
/// all of which use a very hard-to-compute string2key algorithm.
|
||||
/// We would then try to decrypt all of the encrypted session keys
|
||||
/// with all of the known shared secrets.
|
||||
/// In order to prevent this, we do not try to symmetrically decrypt messages
|
||||
/// that use a string2key algorithm other than 'Salted'.
|
||||
fn check_symmetric_encryption(msg: &Message<'_>) -> std::result::Result<(), &'static str> {
|
||||
let Message::Encrypted { esk, .. } = msg else {
|
||||
return Err("not encrypted");
|
||||
};
|
||||
|
||||
if esk.len() > 1 {
|
||||
return Err("too many esks");
|
||||
}
|
||||
|
||||
let [pgp::composed::Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..] else {
|
||||
return Err("not symmetrically encrypted");
|
||||
};
|
||||
|
||||
match esk.s2k() {
|
||||
Some(StringToKey::Salted { .. }) => Ok(()),
|
||||
_ => Err("unsupported string2key algorithm"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns fingerprints
|
||||
/// of all keys from the `public_keys_for_validation` keyring that
|
||||
/// have valid signatures in `msg` and corresponding intended recipient fingerprints
|
||||
@@ -481,7 +367,7 @@ pub async fn symm_encrypt_autocrypt_setup(passphrase: &str, plain: Vec<u8>) -> R
|
||||
/// `shared secret` is the secret that will be used for symmetric encryption.
|
||||
pub async fn symm_encrypt_message(
|
||||
plain: Vec<u8>,
|
||||
private_key_for_signing: SignedSecretKey,
|
||||
private_key_for_signing: Option<SignedSecretKey>,
|
||||
shared_secret: &str,
|
||||
compress: bool,
|
||||
) -> Result<String> {
|
||||
@@ -504,8 +390,10 @@ pub async fn symm_encrypt_message(
|
||||
);
|
||||
msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?;
|
||||
|
||||
let hash_algorithm = private_key_for_signing.hash_alg();
|
||||
msg.sign(&*private_key_for_signing, Password::empty(), hash_algorithm);
|
||||
if let Some(private_key_for_signing) = private_key_for_signing.as_deref() {
|
||||
let hash_algorithm = private_key_for_signing.hash_alg();
|
||||
msg.sign(private_key_for_signing, Password::empty(), hash_algorithm);
|
||||
}
|
||||
if compress {
|
||||
msg.compression(CompressionAlgorithm::ZLIB);
|
||||
}
|
||||
@@ -534,6 +422,166 @@ pub async fn symm_decrypt<T: BufRead + std::fmt::Debug + 'static + Send>(
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Merges and minimizes OpenPGP certificates.
|
||||
///
|
||||
/// Keeps at most one direct key signature and
|
||||
/// at most one User ID with exactly one signature.
|
||||
///
|
||||
/// See <https://openpgp.dev/book/adv/certificates.html#merging>
|
||||
/// and <https://openpgp.dev/book/adv/certificates.html#certificate-minimization>.
|
||||
///
|
||||
/// `new_certificate` does not necessarily contain newer data.
|
||||
/// It may come not directly from the key owner,
|
||||
/// e.g. via protected Autocrypt header or protected attachment
|
||||
/// in a signed message, but from Autocrypt-Gossip header or a vCard.
|
||||
/// Gossiped key may be older than the one we have
|
||||
/// or even have some packets maliciously dropped
|
||||
/// (for example, all encryption subkeys dropped)
|
||||
/// or restored from some older version of the certificate.
|
||||
pub fn merge_openpgp_certificates(
|
||||
old_certificate: SignedPublicKey,
|
||||
new_certificate: SignedPublicKey,
|
||||
) -> Result<SignedPublicKey> {
|
||||
old_certificate
|
||||
.verify_bindings()
|
||||
.context("First key cannot be verified")?;
|
||||
new_certificate
|
||||
.verify_bindings()
|
||||
.context("Second key cannot be verified")?;
|
||||
|
||||
// Decompose certificates.
|
||||
let SignedPublicKey {
|
||||
primary_key: old_primary_key,
|
||||
details: old_details,
|
||||
public_subkeys: old_public_subkeys,
|
||||
} = old_certificate;
|
||||
let SignedPublicKey {
|
||||
primary_key: new_primary_key,
|
||||
details: new_details,
|
||||
public_subkeys: _new_public_subkeys,
|
||||
} = new_certificate;
|
||||
|
||||
// Public keys may be serialized differently, e.g. using old and new packet type,
|
||||
// so we compare imprints instead of comparing the keys
|
||||
// directly with `old_primary_key == new_primary_key`.
|
||||
// Imprints, like fingerprints, are calculated over normalized packets.
|
||||
// On error we print fingerprints as this is what is used in the database
|
||||
// and what most tools show.
|
||||
let old_imprint = old_primary_key.imprint::<Sha256>()?;
|
||||
let new_imprint = new_primary_key.imprint::<Sha256>()?;
|
||||
ensure!(
|
||||
old_imprint == new_imprint,
|
||||
"Cannot merge certificates with different primary keys {} and {}",
|
||||
old_primary_key.fingerprint(),
|
||||
new_primary_key.fingerprint()
|
||||
);
|
||||
|
||||
// Decompose old and the new key details.
|
||||
//
|
||||
// Revocation signatures are currently ignored so we do not store them.
|
||||
//
|
||||
// User attributes are thrown away on purpose,
|
||||
// the only defined in RFC 9580 attribute is the Image Attribute
|
||||
// (<https://www.rfc-editor.org/rfc/rfc9580.html#section-5.12.1>
|
||||
// which we do not use and do not want to gossip.
|
||||
let SignedKeyDetails {
|
||||
revocation_signatures: _old_revocation_signatures,
|
||||
direct_signatures: old_direct_signatures,
|
||||
users: old_users,
|
||||
user_attributes: _old_user_attributes,
|
||||
} = old_details;
|
||||
let SignedKeyDetails {
|
||||
revocation_signatures: _new_revocation_signatures,
|
||||
direct_signatures: new_direct_signatures,
|
||||
users: new_users,
|
||||
user_attributes: _new_user_attributes,
|
||||
} = new_details;
|
||||
|
||||
// Select at most one direct key signature, the newest one.
|
||||
let best_direct_key_signature: Option<Signature> = old_direct_signatures
|
||||
.into_iter()
|
||||
.chain(new_direct_signatures)
|
||||
.filter(|x: &Signature| x.verify_key(&old_primary_key).is_ok())
|
||||
.max_by_key(|x: &Signature|
|
||||
// Converting to seconds because `Ord` is not derived for `Timestamp`:
|
||||
// <https://github.com/rpgp/rpgp/issues/737>
|
||||
x.created().map_or(0, |ts| ts.as_secs()));
|
||||
let direct_signatures: Vec<Signature> = best_direct_key_signature.into_iter().collect();
|
||||
|
||||
// Select at most one User ID.
|
||||
//
|
||||
// We prefer User IDs marked as primary,
|
||||
// but will select non-primary otherwise
|
||||
// because sometimes keys have no primary User ID,
|
||||
// such as Alice's key in `test-data/key/alice-secret.asc`.
|
||||
let best_user: Option<SignedUser> = old_users
|
||||
.into_iter()
|
||||
.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.
|
||||
let best_user_signature: Option<Signature> = signatures
|
||||
.into_iter()
|
||||
.filter(|signature: &Signature| {
|
||||
signature
|
||||
.verify_certification(&old_primary_key, pgp::types::Tag::UserId, &id)
|
||||
.is_ok()
|
||||
})
|
||||
.max_by_key(|signature: &Signature| {
|
||||
signature.created().map_or(0, |ts| ts.as_secs())
|
||||
});
|
||||
best_user_signature.map(|signature| (id, signature))
|
||||
})
|
||||
.max_by_key(|(_id, signature)| signature.created().map_or(0, |ts| ts.as_secs()))
|
||||
.map(|(id, signature)| SignedUser {
|
||||
id,
|
||||
signatures: vec![signature],
|
||||
});
|
||||
let users: Vec<SignedUser> = best_user.into_iter().collect();
|
||||
|
||||
let public_subkeys = old_public_subkeys;
|
||||
|
||||
Ok(SignedPublicKey {
|
||||
primary_key: old_primary_key,
|
||||
details: SignedKeyDetails {
|
||||
revocation_signatures: vec![],
|
||||
direct_signatures,
|
||||
users,
|
||||
user_attributes: vec![],
|
||||
},
|
||||
public_subkeys,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns relays addresses from the public key signature.
|
||||
///
|
||||
/// Not more than 3 relays are returned for each key.
|
||||
pub(crate) fn addresses_from_public_key(public_key: &SignedPublicKey) -> Option<Vec<String>> {
|
||||
for signature in &public_key.details.direct_signatures {
|
||||
// The signature should be verified already when importing the key,
|
||||
// but we double-check here.
|
||||
let signature_is_valid = signature.verify_key(&public_key.primary_key).is_ok();
|
||||
debug_assert!(signature_is_valid);
|
||||
if signature_is_valid {
|
||||
for notation in signature.notations() {
|
||||
if notation.name == "relays@chatmail.at"
|
||||
&& let Ok(value) = str::from_utf8(¬ation.value)
|
||||
{
|
||||
return Some(
|
||||
value
|
||||
.split(",")
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| may_be_valid_addr(s))
|
||||
.take(3)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::LazyLock;
|
||||
@@ -541,14 +589,42 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
key::{load_self_public_key, load_self_secret_key},
|
||||
test_utils::{TestContextManager, alice_keypair, bob_keypair},
|
||||
config::Config,
|
||||
decrypt,
|
||||
key::{load_self_public_key, self_fingerprint, store_self_keypair},
|
||||
mimefactory::{render_outer_message, wrap_encrypted_part},
|
||||
test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair},
|
||||
token,
|
||||
};
|
||||
use pgp::composed::Esk;
|
||||
use pgp::packet::PublicKeyEncryptedSessionKey;
|
||||
|
||||
#[expect(clippy::type_complexity)]
|
||||
fn pk_decrypt_and_validate<'a>(
|
||||
async fn decrypt_bytes(
|
||||
bytes: Vec<u8>,
|
||||
private_keys_for_decryption: &[SignedSecretKey],
|
||||
auth_tokens_for_decryption: &[String],
|
||||
) -> Result<pgp::composed::Message<'static>> {
|
||||
let t = &TestContext::new().await;
|
||||
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
|
||||
.await
|
||||
.expect("Failed to configure address");
|
||||
|
||||
for secret in auth_tokens_for_decryption {
|
||||
token::save(t, token::Namespace::Auth, None, secret, 0).await?;
|
||||
}
|
||||
let [secret_key] = private_keys_for_decryption else {
|
||||
panic!("Only one private key is allowed anymore");
|
||||
};
|
||||
store_self_keypair(t, secret_key).await?;
|
||||
|
||||
let mime_message = wrap_encrypted_part(bytes.try_into().unwrap());
|
||||
let rendered = render_outer_message(vec![], mime_message);
|
||||
let parsed = mailparse::parse_mail(rendered.as_bytes())?;
|
||||
let (decrypted, _fp) = decrypt::decrypt(t, &parsed).await?.unwrap();
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
async fn pk_decrypt_and_validate<'a>(
|
||||
ctext: &'a [u8],
|
||||
private_keys_for_decryption: &'a [SignedSecretKey],
|
||||
public_keys_for_validation: &[SignedPublicKey],
|
||||
@@ -557,7 +633,7 @@ mod tests {
|
||||
HashMap<Fingerprint, Vec<Fingerprint>>,
|
||||
Vec<u8>,
|
||||
)> {
|
||||
let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?;
|
||||
let mut msg = decrypt_bytes(ctext.to_vec(), private_keys_for_decryption, &[]).await?;
|
||||
let content = msg.as_data_vec()?;
|
||||
let ret_signature_fingerprints =
|
||||
valid_signature_fingerprints(&msg, public_keys_for_validation);
|
||||
@@ -596,7 +672,7 @@ mod tests {
|
||||
fn test_create_keypair() {
|
||||
let keypair0 = create_keypair(EmailAddress::new("foo@bar.de").unwrap()).unwrap();
|
||||
let keypair1 = create_keypair(EmailAddress::new("two@zwo.de").unwrap()).unwrap();
|
||||
assert_ne!(keypair0.public, keypair1.public);
|
||||
assert_ne!(keypair0.public_key(), keypair1.public_key());
|
||||
}
|
||||
|
||||
/// [SignedSecretKey] and [SignedPublicKey] objects
|
||||
@@ -613,10 +689,10 @@ mod tests {
|
||||
let alice = alice_keypair();
|
||||
let bob = bob_keypair();
|
||||
TestKeys {
|
||||
alice_secret: alice.secret.clone(),
|
||||
alice_public: alice.public,
|
||||
bob_secret: bob.secret.clone(),
|
||||
bob_public: bob.public,
|
||||
alice_secret: alice.clone(),
|
||||
alice_public: alice.to_public_key(),
|
||||
bob_secret: bob.clone(),
|
||||
bob_public: bob.to_public_key(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -671,6 +747,7 @@ mod tests {
|
||||
&decrypt_keyring,
|
||||
&sig_check_keyring,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, CLEARTEXT);
|
||||
assert_eq!(valid_signatures.len(), 1);
|
||||
@@ -686,6 +763,7 @@ mod tests {
|
||||
&decrypt_keyring,
|
||||
&sig_check_keyring,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, CLEARTEXT);
|
||||
assert_eq!(valid_signatures.len(), 1);
|
||||
@@ -698,7 +776,9 @@ mod tests {
|
||||
async fn test_decrypt_no_sig_check() {
|
||||
let keyring = vec![KEYS.alice_secret.clone()];
|
||||
let (_msg, valid_signatures, content) =
|
||||
pk_decrypt_and_validate(ctext_signed().await.as_bytes(), &keyring, &[]).unwrap();
|
||||
pk_decrypt_and_validate(ctext_signed().await.as_bytes(), &keyring, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, CLEARTEXT);
|
||||
assert_eq!(valid_signatures.len(), 0);
|
||||
}
|
||||
@@ -713,6 +793,7 @@ mod tests {
|
||||
&decrypt_keyring,
|
||||
&sig_check_keyring,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, CLEARTEXT);
|
||||
assert_eq!(valid_signatures.len(), 0);
|
||||
@@ -723,57 +804,64 @@ mod tests {
|
||||
let decrypt_keyring = vec![KEYS.bob_secret.clone()];
|
||||
let ctext_unsigned = include_bytes!("../test-data/message/ctext_unsigned.asc");
|
||||
let (_msg, valid_signatures, content) =
|
||||
pk_decrypt_and_validate(ctext_unsigned, &decrypt_keyring, &[]).unwrap();
|
||||
pk_decrypt_and_validate(ctext_unsigned, &decrypt_keyring, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, CLEARTEXT);
|
||||
assert_eq!(valid_signatures.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
async fn test_dont_decrypt_expensive_message_happy_path() -> Result<()> {
|
||||
let s2k = StringToKey::Salted {
|
||||
hash_alg: HashAlgorithm::default(),
|
||||
salt: [1; 8],
|
||||
};
|
||||
|
||||
let plain = Vec::from(b"this is the secret message");
|
||||
let shared_secret = "shared secret";
|
||||
let ctext = symm_encrypt_message(
|
||||
plain.clone(),
|
||||
load_self_secret_key(alice).await?,
|
||||
shared_secret,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
test_dont_decrypt_expensive_message_ex(s2k, false, None).await
|
||||
}
|
||||
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let mut decrypted = decrypt(
|
||||
ctext.into(),
|
||||
&bob_private_keyring,
|
||||
&[shared_secret.to_string()],
|
||||
)?;
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_dont_decrypt_expensive_message_bad_s2k() -> Result<()> {
|
||||
let s2k = StringToKey::new_default(&mut thread_rng()); // Default is IteratedAndSalted
|
||||
|
||||
assert_eq!(decrypted.as_data_vec()?, plain);
|
||||
test_dont_decrypt_expensive_message_ex(s2k, false, Some("unsupported string2key algorithm"))
|
||||
.await
|
||||
}
|
||||
|
||||
Ok(())
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_dont_decrypt_expensive_message_multiple_secrets() -> Result<()> {
|
||||
let s2k = StringToKey::Salted {
|
||||
hash_alg: HashAlgorithm::default(),
|
||||
salt: [1; 8],
|
||||
};
|
||||
|
||||
// This error message is actually not great,
|
||||
// but grepping for it will lead to the correct code
|
||||
test_dont_decrypt_expensive_message_ex(s2k, true, Some("decrypt_with_keys: missing key"))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Test that we don't try to decrypt a message
|
||||
/// that is symmetrically encrypted
|
||||
/// with an expensive string2key algorithm
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_dont_decrypt_expensive_message() -> Result<()> {
|
||||
/// or multiple shared secrets.
|
||||
/// This is to prevent possible DOS attacks on the app.
|
||||
async fn test_dont_decrypt_expensive_message_ex(
|
||||
s2k: StringToKey,
|
||||
encrypt_twice: bool,
|
||||
expected_error_msg: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let plain = Vec::from(b"this is the secret message");
|
||||
let shared_secret = "shared secret";
|
||||
let bob_fp = self_fingerprint(bob).await?;
|
||||
|
||||
// Create a symmetrically encrypted message
|
||||
// with an IteratedAndSalted string2key algorithm:
|
||||
|
||||
let shared_secret_pw = Password::from(shared_secret.to_string());
|
||||
let shared_secret_pw = Password::from(format!("securejoin/{bob_fp}/{shared_secret}"));
|
||||
let msg = MessageBuilder::from_bytes("", plain);
|
||||
let mut rng = thread_rng();
|
||||
let s2k = StringToKey::new_default(&mut rng); // Default is IteratedAndSalted
|
||||
|
||||
let mut msg = msg.seipd_v2(
|
||||
&mut rng,
|
||||
@@ -781,24 +869,28 @@ mod tests {
|
||||
AeadAlgorithm::Ocb,
|
||||
ChunkSize::C8KiB,
|
||||
);
|
||||
msg.encrypt_with_password(&mut rng, s2k, &shared_secret_pw)?;
|
||||
msg.encrypt_with_password(&mut rng, s2k.clone(), &shared_secret_pw)?;
|
||||
if encrypt_twice {
|
||||
msg.encrypt_with_password(&mut rng, s2k, &shared_secret_pw)?;
|
||||
}
|
||||
|
||||
let ctext = msg.to_armored_string(&mut rng, Default::default())?;
|
||||
|
||||
// Trying to decrypt it should fail with a helpful error message:
|
||||
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let error = decrypt(
|
||||
let res = decrypt_bytes(
|
||||
ctext.into(),
|
||||
&bob_private_keyring,
|
||||
&[shared_secret.to_string()],
|
||||
)
|
||||
.unwrap_err();
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"missing key (Note: symmetric decryption was not tried: unsupported string2key algorithm)"
|
||||
);
|
||||
if let Some(expected_error_msg) = expected_error_msg {
|
||||
assert_eq!(format!("{:#}", res.unwrap_err()), expected_error_msg);
|
||||
} else {
|
||||
res.unwrap();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -825,12 +917,11 @@ mod tests {
|
||||
|
||||
// Trying to decrypt it should fail with an OK error message:
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let error = decrypt(ctext.into(), &bob_private_keyring, &[]).unwrap_err();
|
||||
let error = decrypt_bytes(ctext.into(), &bob_private_keyring, &[])
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"missing key (Note: symmetric decryption was not tried: not symmetrically encrypted)"
|
||||
);
|
||||
assert_eq!(format!("{error:#}"), "decrypt_with_keys: missing key");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -864,4 +955,24 @@ mod tests {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_openpgp_certificates() {
|
||||
let alice = alice_keypair().to_public_key();
|
||||
let bob = bob_keypair().to_public_key();
|
||||
|
||||
// Merging certificate with itself does not change it.
|
||||
assert_eq!(
|
||||
merge_openpgp_certificates(alice.clone(), alice.clone()).unwrap(),
|
||||
alice
|
||||
);
|
||||
assert_eq!(
|
||||
merge_openpgp_certificates(bob.clone(), bob.clone()).unwrap(),
|
||||
bob
|
||||
);
|
||||
|
||||
// Cannot merge certificates with different primary key.
|
||||
assert!(merge_openpgp_certificates(alice.clone(), bob.clone()).is_err());
|
||||
assert!(merge_openpgp_certificates(bob.clone(), alice.clone()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
38
src/qr.rs
38
src/qr.rs
@@ -17,7 +17,7 @@ use crate::config::Config;
|
||||
use crate::contact::{Contact, ContactId, Origin};
|
||||
use crate::context::Context;
|
||||
use crate::key::Fingerprint;
|
||||
use crate::login_param::{EnteredCertificateChecks, EnteredLoginParam, EnteredServerLoginParam};
|
||||
use crate::login_param::{EnteredCertificateChecks, EnteredImapLoginParam, EnteredLoginParam};
|
||||
use crate::net::http::post_empty;
|
||||
use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig};
|
||||
use crate::token;
|
||||
@@ -41,7 +41,7 @@ pub(crate) const DCBACKUP_SCHEME_PREFIX: &str = "DCBACKUP";
|
||||
|
||||
/// Version written to Backups and Backup-QR-Codes.
|
||||
/// Imports will fail when they have a larger version.
|
||||
pub(crate) const DCBACKUP_VERSION: i32 = 4;
|
||||
pub(crate) const DCBACKUP_VERSION: i32 = 5;
|
||||
|
||||
/// Scanned QR code.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -61,6 +61,9 @@ pub enum Qr {
|
||||
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
|
||||
/// Ask the user whether to join the group.
|
||||
@@ -82,6 +85,9 @@ pub enum Qr {
|
||||
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
|
||||
/// Ask whether to join the broadcast channel.
|
||||
@@ -106,6 +112,9 @@ pub enum Qr {
|
||||
invitenumber: String,
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
|
||||
/// Whether the inviter supports the new Securejoin v3 protocol
|
||||
is_v3: bool,
|
||||
},
|
||||
|
||||
/// Contact fingerprint is verified.
|
||||
@@ -483,7 +492,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
|
||||
let name = decode_name(¶m, "n")?.unwrap_or_default();
|
||||
|
||||
let invitenumber = param
|
||||
let mut invitenumber = param
|
||||
.get("i")
|
||||
// For historic reansons, broadcasts currently use j instead of i for the invitenumber:
|
||||
.or_else(|| param.get("j"))
|
||||
@@ -501,6 +510,16 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
let grpname = decode_name(¶m, "g")?;
|
||||
let broadcast_name = decode_name(¶m, "b")?;
|
||||
|
||||
let mut is_v3 = param.get("v") == Some(&"3");
|
||||
|
||||
if authcode.is_some() && invitenumber.is_none() {
|
||||
// Securejoin v3 doesn't need an invitenumber.
|
||||
// We want to remove the invitenumber and the `v=3` parameter eventually;
|
||||
// therefore, we accept v3 QR codes without an invitenumber.
|
||||
is_v3 = true;
|
||||
invitenumber = Some("".to_string());
|
||||
}
|
||||
|
||||
if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
|
||||
let addr = ContactAddress::new(addr)?;
|
||||
let (contact_id, _) = Contact::add_or_lookup_ex(
|
||||
@@ -519,7 +538,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
.await
|
||||
.with_context(|| format!("can't check if address {addr:?} is our address"))?
|
||||
{
|
||||
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
|
||||
if token::exists(context, token::Namespace::Auth, &authcode).await? {
|
||||
Ok(Qr::WithdrawVerifyGroup {
|
||||
grpname,
|
||||
grpid,
|
||||
@@ -546,6 +565,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
})
|
||||
}
|
||||
} else if let (Some(grpid), Some(name)) = (grpid, broadcast_name) {
|
||||
@@ -554,7 +574,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
.await
|
||||
.with_context(|| format!("Can't check if {addr:?} is our address"))?
|
||||
{
|
||||
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
|
||||
if token::exists(context, token::Namespace::Auth, &authcode).await? {
|
||||
Ok(Qr::WithdrawJoinBroadcast {
|
||||
name,
|
||||
grpid,
|
||||
@@ -581,10 +601,11 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
})
|
||||
}
|
||||
} else if context.is_self_addr(&addr).await? {
|
||||
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
|
||||
if token::exists(context, token::Namespace::Auth, &authcode).await? {
|
||||
Ok(Qr::WithdrawVerifyContact {
|
||||
contact_id,
|
||||
fingerprint,
|
||||
@@ -605,6 +626,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
})
|
||||
}
|
||||
} else if let Some(addr) = addr {
|
||||
@@ -790,7 +812,7 @@ pub(crate) async fn login_param_from_account_qr(
|
||||
|
||||
let param = EnteredLoginParam {
|
||||
addr,
|
||||
imap: EnteredServerLoginParam {
|
||||
imap: EnteredImapLoginParam {
|
||||
password,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -810,7 +832,7 @@ pub(crate) async fn login_param_from_account_qr(
|
||||
|
||||
let param = EnteredLoginParam {
|
||||
addr: email,
|
||||
imap: EnteredServerLoginParam {
|
||||
imap: EnteredImapLoginParam {
|
||||
password,
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -5,7 +5,9 @@ use anyhow::{Context as _, Result, bail};
|
||||
use deltachat_contact_tools::may_be_valid_addr;
|
||||
|
||||
use super::{DCLOGIN_SCHEME, Qr};
|
||||
use crate::login_param::{EnteredCertificateChecks, EnteredLoginParam, EnteredServerLoginParam};
|
||||
use crate::login_param::{
|
||||
EnteredCertificateChecks, EnteredImapLoginParam, EnteredLoginParam, EnteredSmtpLoginParam,
|
||||
};
|
||||
use crate::provider::Socket;
|
||||
|
||||
/// Options for `dclogin:` scheme.
|
||||
@@ -81,9 +83,14 @@ pub(super) fn decode_login(qr: &str) -> Result<Qr> {
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
|
||||
let addr = percent_encoding::percent_decode_str(addr)
|
||||
.decode_utf8()
|
||||
.context("Address must be UTF-8")?
|
||||
.to_string();
|
||||
|
||||
// check if username is there
|
||||
if !may_be_valid_addr(addr) {
|
||||
bail!("invalid DCLOGIN payload: invalid username E5");
|
||||
if !may_be_valid_addr(&addr) {
|
||||
bail!("Invalid DCLOGIN payload: invalid username {addr:?}.");
|
||||
}
|
||||
|
||||
// apply to result struct
|
||||
@@ -173,14 +180,15 @@ pub(crate) fn login_param_from_login_qr(
|
||||
} => {
|
||||
let param = EnteredLoginParam {
|
||||
addr: addr.to_string(),
|
||||
imap: EnteredServerLoginParam {
|
||||
imap: EnteredImapLoginParam {
|
||||
server: imap_host.unwrap_or_default(),
|
||||
port: imap_port.unwrap_or_default(),
|
||||
folder: "INBOX".to_string(),
|
||||
security: imap_security.unwrap_or_default(),
|
||||
user: imap_username.unwrap_or_default(),
|
||||
password: imap_password.unwrap_or(mail_pw),
|
||||
},
|
||||
smtp: EnteredServerLoginParam {
|
||||
smtp: EnteredSmtpLoginParam {
|
||||
server: smtp_host.unwrap_or_default(),
|
||||
port: smtp_port.unwrap_or_default(),
|
||||
security: smtp_security.unwrap_or_default(),
|
||||
@@ -200,9 +208,7 @@ pub(crate) fn login_param_from_login_qr(
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use anyhow::bail;
|
||||
|
||||
use super::{LoginOptions, decode_login};
|
||||
use super::*;
|
||||
use crate::{login_param::EnteredCertificateChecks, provider::Socket, qr::Qr};
|
||||
|
||||
macro_rules! login_options_just_pw {
|
||||
@@ -225,7 +231,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_no_options() -> anyhow::Result<()> {
|
||||
fn minimal_no_options() -> Result<()> {
|
||||
let result = decode_login("dclogin://email@host.tld?p=123&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "email@host.tld".to_owned());
|
||||
@@ -250,7 +256,7 @@ mod test {
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn minimal_no_options_no_double_slash() -> anyhow::Result<()> {
|
||||
fn minimal_no_options_no_double_slash() -> Result<()> {
|
||||
let result = decode_login("dclogin:email@host.tld?p=123&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "email@host.tld".to_owned());
|
||||
@@ -289,7 +295,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_too_new() -> anyhow::Result<()> {
|
||||
fn version_too_new() -> Result<()> {
|
||||
let result = decode_login("dclogin:email@host.tld/?p=123456&v=2")?;
|
||||
if let Qr::Login { options, .. } = result {
|
||||
assert_eq!(options, LoginOptions::UnsuportedVersion(2));
|
||||
@@ -306,7 +312,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_advanced_options() -> anyhow::Result<()> {
|
||||
fn all_advanced_options() -> Result<()> {
|
||||
let result = decode_login(
|
||||
"dclogin:email@host.tld?p=secret&v=1&ih=imap.host.tld&ip=4000&iu=max&ipw=87654&is=ssl&ic=1&sh=mail.host.tld&sp=3000&su=max@host.tld&spw=3242HS&ss=plain&sc=3",
|
||||
)?;
|
||||
@@ -336,7 +342,19 @@ mod test {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_encoded_password() -> anyhow::Result<()> {
|
||||
fn uri_encoded_login() -> Result<()> {
|
||||
let result = decode_login("dclogin:username@%5b192.168.1.1%5d?p=1234&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "username@[192.168.1.1]".to_owned());
|
||||
assert_eq!(options, login_options_just_pw!("1234".to_owned()));
|
||||
} else {
|
||||
bail!("wrong type")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_encoded_password() -> Result<()> {
|
||||
let result = decode_login(
|
||||
"dclogin:email@host.tld?p=%7BDaehFl%3B%22as%40%21fhdodn5%24234%22%7B%7Dfg&v=1",
|
||||
)?;
|
||||
@@ -353,7 +371,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_with_plus_extension() -> anyhow::Result<()> {
|
||||
fn email_with_plus_extension() -> Result<()> {
|
||||
let result = decode_login("dclogin:usename+extension@host?p=1234&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "usename+extension@host".to_owned());
|
||||
@@ -365,7 +383,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decode_dclogin_ipv4() -> anyhow::Result<()> {
|
||||
async fn test_decode_dclogin_ipv4() -> Result<()> {
|
||||
let result = decode_login("dclogin://test@[127.0.0.1]?p=1234&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "test@[127.0.0.1]".to_owned());
|
||||
@@ -377,7 +395,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decode_dclogin_ipv6() -> anyhow::Result<()> {
|
||||
async fn test_decode_dclogin_ipv6() -> Result<()> {
|
||||
let result =
|
||||
decode_login("dclogin://test@[2001:0db8:85a3:0000:0000:8a2e:0370:7334]?p=1234&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use regex::Regex;
|
||||
|
||||
use super::*;
|
||||
use crate::chat::{Chat, create_broadcast, create_group, get_chat_contacts};
|
||||
use crate::config::Config;
|
||||
@@ -445,9 +447,28 @@ async fn test_decode_openpgp_without_addr() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_withdraw_verifycontact() -> Result<()> {
|
||||
async fn test_withdraw_verifycontact_basic() -> Result<()> {
|
||||
test_withdraw_verifycontact(false).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_withdraw_verifycontact_without_invite() -> Result<()> {
|
||||
test_withdraw_verifycontact(true).await
|
||||
}
|
||||
|
||||
async fn test_withdraw_verifycontact(remove_invite: bool) -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
let qr = get_securejoin_qr(&alice, None).await?;
|
||||
let mut qr = get_securejoin_qr(&alice, None).await?;
|
||||
|
||||
if remove_invite {
|
||||
// Remove the INVITENUBMER. It's not needed in Securejoin v3,
|
||||
// but still included for backwards compatibility reasons.
|
||||
// We want to be able to remove it in the future,
|
||||
// therefore we test that things work without it.
|
||||
let new_qr = Regex::new("&i=.*?&").unwrap().replace(&qr, "&");
|
||||
assert!(new_qr != *qr);
|
||||
qr = new_qr.to_string();
|
||||
}
|
||||
|
||||
// scanning own verify-contact code offers withdrawing
|
||||
assert!(matches!(
|
||||
@@ -466,6 +487,11 @@ async fn test_withdraw_verifycontact() -> Result<()> {
|
||||
check_qr(&alice, &qr).await?,
|
||||
Qr::WithdrawVerifyContact { .. }
|
||||
));
|
||||
// Test that removing the INVITENUMBER doesn't result in saving empty token:
|
||||
assert_eq!(
|
||||
token::exists(&alice, token::Namespace::InviteNumber, "").await?,
|
||||
false
|
||||
);
|
||||
|
||||
// someone else always scans as ask-verify-contact
|
||||
let bob = TestContext::new_bob().await;
|
||||
|
||||
44
src/quota.rs
44
src/quota.rs
@@ -9,7 +9,6 @@ use async_imap::types::{Quota, QuotaResource};
|
||||
use crate::chat::add_device_msg_with_importance;
|
||||
use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
use crate::imap::get_watched_folders;
|
||||
use crate::imap::session::Session as ImapSession;
|
||||
use crate::log::warn;
|
||||
use crate::message::Message;
|
||||
@@ -48,26 +47,24 @@ pub struct QuotaInfo {
|
||||
|
||||
async fn get_unique_quota_roots_and_usage(
|
||||
session: &mut ImapSession,
|
||||
folders: Vec<String>,
|
||||
folder: String,
|
||||
) -> Result<BTreeMap<String, Vec<QuotaResource>>> {
|
||||
let mut unique_quota_roots: BTreeMap<String, Vec<QuotaResource>> = BTreeMap::new();
|
||||
for folder in folders {
|
||||
let (quota_roots, quotas) = &session.get_quota_root(&folder).await?;
|
||||
// if there are new quota roots found in this imap folder, add them to the list
|
||||
for qr_entries in quota_roots {
|
||||
for quota_root_name in &qr_entries.quota_root_names {
|
||||
// the quota for that quota root
|
||||
let quota: Quota = quotas
|
||||
.iter()
|
||||
.find(|q| &q.root_name == quota_root_name)
|
||||
.cloned()
|
||||
.context("quota_root should have a quota")?;
|
||||
// replace old quotas, because between fetching quotaroots for folders,
|
||||
// messages could be received and so the usage could have been changed
|
||||
*unique_quota_roots
|
||||
.entry(quota_root_name.clone())
|
||||
.or_default() = quota.resources;
|
||||
}
|
||||
let (quota_roots, quotas) = &session.get_quota_root(&folder).await?;
|
||||
// if there are new quota roots found in this imap folder, add them to the list
|
||||
for qr_entries in quota_roots {
|
||||
for quota_root_name in &qr_entries.quota_root_names {
|
||||
// the quota for that quota root
|
||||
let quota: Quota = quotas
|
||||
.iter()
|
||||
.find(|q| &q.root_name == quota_root_name)
|
||||
.cloned()
|
||||
.context("quota_root should have a quota")?;
|
||||
// replace old quotas, because between fetching quotaroots for folders,
|
||||
// messages could be received and so the usage could have been changed
|
||||
*unique_quota_roots
|
||||
.entry(quota_root_name.clone())
|
||||
.or_default() = quota.resources;
|
||||
}
|
||||
}
|
||||
Ok(unique_quota_roots)
|
||||
@@ -123,10 +120,13 @@ impl Context {
|
||||
/// As the message is added only once, the user is not spammed
|
||||
/// in case for some providers the quota is always at ~100%
|
||||
/// and new space is allocated as needed.
|
||||
pub(crate) async fn update_recent_quota(&self, session: &mut ImapSession) -> Result<()> {
|
||||
pub(crate) async fn update_recent_quota(
|
||||
&self,
|
||||
session: &mut ImapSession,
|
||||
folder: String,
|
||||
) -> Result<()> {
|
||||
let quota = if session.can_check_quota() {
|
||||
let folders = get_watched_folders(self).await?;
|
||||
get_unique_quota_roots_and_usage(session, folders).await
|
||||
get_unique_quota_roots_and_usage(session, folder).await
|
||||
} else {
|
||||
Err(anyhow!(stock_str::not_supported_by_provider(self).await))
|
||||
};
|
||||
|
||||
154
src/reaction.rs
154
src/reaction.rs
@@ -393,7 +393,9 @@ mod tests {
|
||||
use crate::chatlist::Chatlist;
|
||||
use crate::config::Config;
|
||||
use crate::contact::{Contact, Origin};
|
||||
use crate::message::{MessageState, Viewtype, delete_msgs};
|
||||
use crate::key::{load_self_public_key, load_self_secret_key};
|
||||
use crate::message::{MessageState, Viewtype, delete_msgs, markseen_msgs};
|
||||
use crate::pgp::{SeipdVersion, pk_encrypt};
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::sql::housekeeping;
|
||||
use crate::test_utils::E2EE_INFO_MSGS;
|
||||
@@ -956,4 +958,154 @@ Content-Disposition: reaction\n\
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that if reaction requests a read receipt,
|
||||
/// no read receipt is sent when the chat is marked as noticed.
|
||||
///
|
||||
/// Reactions create hidden messages in the chat,
|
||||
/// and when marking the chat as noticed marks
|
||||
/// such messages as seen, read receipts should never be sent
|
||||
/// to avoid the sender of reaction from learning
|
||||
/// that receiver opened the chat.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_reaction_request_mdn() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat_id = alice.create_chat_id(bob).await;
|
||||
let alice_sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
|
||||
|
||||
let bob_msg = bob.recv_msg(&alice_sent_msg).await;
|
||||
bob_msg.chat_id.accept(bob).await?;
|
||||
assert_eq!(bob_msg.state, MessageState::InFresh);
|
||||
let bob_chat_id = bob_msg.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
|
||||
markseen_msgs(bob, vec![bob_msg.id]).await?;
|
||||
assert_eq!(
|
||||
bob.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?",
|
||||
(ContactId::SELF,)
|
||||
)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
bob.sql.execute("DELETE FROM smtp_mdns", ()).await?;
|
||||
|
||||
// Construct reaction with an MDN request.
|
||||
// Note the `Chat-Disposition-Notification-To` header.
|
||||
let known_id = bob_msg.rfc724_mid;
|
||||
let new_id = "e2b6e69e-4124-4e2a-b79f-e4f1be667165@localhost";
|
||||
|
||||
let plain_text = format!(
|
||||
"Content-Type: text/plain; charset=\"utf-8\"; protected-headers=\"v1\"; \r
|
||||
hp=\"cipher\"\r
|
||||
Content-Disposition: reaction\r
|
||||
From: \"Alice\" <alice@example.org>\r
|
||||
To: \"Bob\" <bob@example.net>\r
|
||||
Subject: Message from Alice\r
|
||||
Date: Sat, 14 Mar 2026 01:02:03 +0000\r
|
||||
In-Reply-To: <{known_id}>\r
|
||||
References: <{known_id}>\r
|
||||
Chat-Version: 1.0\r
|
||||
Chat-Disposition-Notification-To: alice@example.org\r
|
||||
Message-ID: <{new_id}>\r
|
||||
HP-Outer: From: <alice@example.org>\r
|
||||
HP-Outer: To: \"hidden-recipients\": ;\r
|
||||
HP-Outer: Subject: [...]\r
|
||||
HP-Outer: Date: Sat, 14 Mar 2026 01:02:03 +0000\r
|
||||
HP-Outer: Message-ID: <{new_id}>\r
|
||||
HP-Outer: In-Reply-To: <{known_id}>\r
|
||||
HP-Outer: References: <{known_id}>\r
|
||||
HP-Outer: Chat-Version: 1.0\r
|
||||
Content-Transfer-Encoding: base64\r
|
||||
\r
|
||||
8J+RgA==\r
|
||||
"
|
||||
);
|
||||
|
||||
let alice_public_key = load_self_public_key(alice).await?;
|
||||
let bob_public_key = load_self_public_key(bob).await?;
|
||||
let alice_secret_key = load_self_secret_key(alice).await?;
|
||||
let public_keys_for_encryption = vec![alice_public_key, bob_public_key];
|
||||
let compress = true;
|
||||
let anonymous_recipients = true;
|
||||
let encrypted_payload = pk_encrypt(
|
||||
plain_text.as_bytes().to_vec(),
|
||||
public_keys_for_encryption,
|
||||
alice_secret_key,
|
||||
compress,
|
||||
anonymous_recipients,
|
||||
SeipdVersion::V2,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let boundary = "boundary123";
|
||||
let rcvd_mail = format!(
|
||||
"From: <alice@example.org>\r
|
||||
To: \"hidden-recipients\": ;\r
|
||||
Subject: [...]\r
|
||||
Date: Sat, 14 Mar 2026 01:02:03 +0000\r
|
||||
Message-ID: <{new_id}>\r
|
||||
In-Reply-To: <{known_id}>\r
|
||||
References: <{known_id}>\r
|
||||
Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\";\r
|
||||
boundary=\"{boundary}\"\r
|
||||
MIME-Version: 1.0\r
|
||||
\r
|
||||
--{boundary}\r
|
||||
Content-Type: application/pgp-encrypted; charset=\"utf-8\"\r
|
||||
Content-Description: PGP/MIME version identification\r
|
||||
Content-Transfer-Encoding: 7bit\r
|
||||
\r
|
||||
Version: 1\r
|
||||
\r
|
||||
--{boundary}\r
|
||||
Content-Type: application/octet-stream; name=\"encrypted.asc\";\r
|
||||
charset=\"utf-8\"\r
|
||||
Content-Description: OpenPGP encrypted message\r
|
||||
Content-Disposition: inline; filename=\"encrypted.asc\";\r
|
||||
Content-Transfer-Encoding: 7bit\r
|
||||
\r
|
||||
{encrypted_payload}
|
||||
--{boundary}--\r
|
||||
"
|
||||
);
|
||||
|
||||
let received = receive_imf(bob, rcvd_mail.as_bytes(), false)
|
||||
.await?
|
||||
.unwrap();
|
||||
let bob_hidden_msg = Message::load_from_db(bob, *received.msg_ids.last().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(bob_hidden_msg.hidden);
|
||||
assert_eq!(bob_hidden_msg.chat_id, bob_chat_id);
|
||||
|
||||
// Bob does not see new message and cannot mark it as seen directly,
|
||||
// but can mark the chat as noticed when opening it.
|
||||
marknoticed_chat(bob, bob_chat_id).await?;
|
||||
|
||||
assert_eq!(
|
||||
bob.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?",
|
||||
(ContactId::SELF,)
|
||||
)
|
||||
.await?,
|
||||
0,
|
||||
"Bob should not send MDN to Alice"
|
||||
);
|
||||
|
||||
// MDN request was ignored, but reaction was not.
|
||||
let reactions = get_msg_reactions(bob, bob_msg.id).await?;
|
||||
assert_eq!(reactions.reactions.len(), 1);
|
||||
assert_eq!(
|
||||
reactions.emoji_sorted_by_frequency(),
|
||||
vec![("👀".to_string(), 1)]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ use crate::key::{
|
||||
};
|
||||
use crate::log::{LogExt as _, warn};
|
||||
use crate::message::{
|
||||
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, rfc724_mid_exists,
|
||||
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, insert_tombstone,
|
||||
rfc724_mid_exists,
|
||||
};
|
||||
use crate::mimeparser::{
|
||||
AvatarAction, GossipedKey, MimeMessage, PreMessageMode, SystemMessage, parse_message_ids,
|
||||
@@ -178,22 +179,6 @@ pub(crate) async fn receive_imf_from_inbox(
|
||||
receive_imf_inner(context, rfc724_mid, imf_raw, seen).await
|
||||
}
|
||||
|
||||
/// Inserts a tombstone into `msgs` table
|
||||
/// to prevent downloading the same message in the future.
|
||||
///
|
||||
/// Returns tombstone database row ID.
|
||||
async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId> {
|
||||
let row_id = context
|
||||
.sql
|
||||
.insert(
|
||||
"INSERT INTO msgs(rfc724_mid, chat_id) VALUES (?,?)",
|
||||
(rfc724_mid, DC_CHAT_ID_TRASH),
|
||||
)
|
||||
.await?;
|
||||
let msg_id = MsgId::new(u32::try_from(row_id)?);
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
||||
async fn get_to_and_past_contact_ids(
|
||||
context: &Context,
|
||||
mime_parser: &MimeMessage,
|
||||
@@ -554,9 +539,17 @@ pub(crate) async fn receive_imf_inner(
|
||||
.await?
|
||||
.filter(|msg| msg.download_state() != DownloadState::Done)
|
||||
{
|
||||
// the message was partially downloaded before and is fully downloaded now.
|
||||
info!(context, "Message already partly in DB, replacing.");
|
||||
Some(msg.chat_id)
|
||||
// The message was partially downloaded before.
|
||||
match mime_parser.pre_message {
|
||||
PreMessageMode::Post | PreMessageMode::None => {
|
||||
info!(context, "Message already partly in DB, replacing.");
|
||||
Some(msg.chat_id)
|
||||
}
|
||||
PreMessageMode::Pre { .. } => {
|
||||
info!(context, "Cannot replace pre-message with a pre-message");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The message was already fully downloaded
|
||||
// or cannot be loaded because it is deleted.
|
||||
@@ -868,6 +861,10 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
|
||||
if transport_changed {
|
||||
info!(context, "Primary transport changed to {from_addr:?}.");
|
||||
context.sql.uncache_raw_config("configured_addr").await;
|
||||
|
||||
// Regenerate User ID in V4 keys.
|
||||
context.self_public_key.lock().await.take();
|
||||
|
||||
context.emit_event(EventType::TransportsModified);
|
||||
}
|
||||
} else {
|
||||
@@ -3339,8 +3336,13 @@ async fn apply_chat_name_avatar_and_description_changes(
|
||||
.is_some()
|
||||
{
|
||||
let old_name = &sanitize_single_line(old_name);
|
||||
better_msg
|
||||
.get_or_insert(stock_str::msg_grp_name(context, old_name, grpname, from_id).await);
|
||||
better_msg.get_or_insert(
|
||||
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
|
||||
stock_str::msg_broadcast_name_changed(context, old_name, grpname).await
|
||||
} else {
|
||||
stock_str::msg_grp_name(context, old_name, grpname, from_id).await
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3397,10 +3399,18 @@ async fn apply_chat_name_avatar_and_description_changes(
|
||||
{
|
||||
// this is just an explicit message containing the group-avatar,
|
||||
// apart from that, the group-avatar is send along with various other messages
|
||||
better_msg.get_or_insert(match avatar_action {
|
||||
AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await,
|
||||
AvatarAction::Change(_) => stock_str::msg_grp_img_changed(context, from_id).await,
|
||||
});
|
||||
better_msg.get_or_insert(
|
||||
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
|
||||
stock_str::msg_broadcast_img_changed(context).await
|
||||
} else {
|
||||
match avatar_action {
|
||||
AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await,
|
||||
AvatarAction::Change(_) => {
|
||||
stock_str::msg_grp_img_changed(context, from_id).await
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(avatar_action) = &mime_parser.group_avatar {
|
||||
|
||||
193
src/scheduler.rs
193
src/scheduler.rs
@@ -17,7 +17,7 @@ use crate::context::Context;
|
||||
use crate::download::{download_known_post_messages_without_pre_message, download_msgs};
|
||||
use crate::ephemeral::{self, delete_expired_imap_messages};
|
||||
use crate::events::EventType;
|
||||
use crate::imap::{FolderMeaning, Imap, session::Session};
|
||||
use crate::imap::{Imap, session::Session};
|
||||
use crate::location;
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::smtp::{Smtp, send_smtp_messages};
|
||||
@@ -211,25 +211,19 @@ impl SchedulerState {
|
||||
/// Indicate that the network likely has come back.
|
||||
pub(crate) async fn maybe_network(&self) {
|
||||
let inner = self.inner.read().await;
|
||||
let (inboxes, oboxes) = match *inner {
|
||||
let inboxes = match *inner {
|
||||
InnerSchedulerState::Started(ref scheduler) => {
|
||||
scheduler.maybe_network();
|
||||
let inboxes = scheduler
|
||||
scheduler
|
||||
.inboxes
|
||||
.iter()
|
||||
.map(|b| b.conn_state.state.connectivity.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let oboxes = scheduler
|
||||
.oboxes
|
||||
.iter()
|
||||
.map(|b| b.conn_state.state.connectivity.clone())
|
||||
.collect::<Vec<_>>();
|
||||
(inboxes, oboxes)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
drop(inner);
|
||||
connectivity::idle_interrupted(inboxes, oboxes);
|
||||
connectivity::idle_interrupted(inboxes);
|
||||
}
|
||||
|
||||
/// Indicate that the network likely is lost.
|
||||
@@ -318,7 +312,10 @@ impl Drop for IoPausedGuard {
|
||||
struct SchedBox {
|
||||
/// Address at the used chatmail/email relay
|
||||
addr: String,
|
||||
meaning: FolderMeaning,
|
||||
|
||||
/// Folder name
|
||||
folder: String,
|
||||
|
||||
conn_state: ImapConnectionState,
|
||||
|
||||
/// IMAP loop task handle.
|
||||
@@ -330,8 +327,6 @@ struct SchedBox {
|
||||
pub(crate) struct Scheduler {
|
||||
/// Inboxes, one per transport.
|
||||
inboxes: Vec<SchedBox>,
|
||||
/// Optional boxes -- mvbox.
|
||||
oboxes: Vec<SchedBox>,
|
||||
smtp: SmtpConnectionState,
|
||||
smtp_handle: task::JoinHandle<()>,
|
||||
ephemeral_handle: task::JoinHandle<()>,
|
||||
@@ -400,48 +395,11 @@ async fn inbox_loop(
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Convert folder meaning
|
||||
/// used internally by [fetch_idle] and [Context::background_fetch].
|
||||
///
|
||||
/// Returns folder configuration key and folder name
|
||||
/// if such folder is configured, `Ok(None)` otherwise.
|
||||
pub async fn convert_folder_meaning(
|
||||
ctx: &Context,
|
||||
folder_meaning: FolderMeaning,
|
||||
) -> Result<Option<(Config, String)>> {
|
||||
let folder_config = match folder_meaning.to_config() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// Such folder cannot be configured,
|
||||
// e.g. a `FolderMeaning::Spam` folder.
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let folder = ctx
|
||||
.get_config(folder_config)
|
||||
.await
|
||||
.with_context(|| format!("Failed to retrieve {folder_config} folder"))?;
|
||||
|
||||
if let Some(watch_folder) = folder {
|
||||
Ok(Some((folder_config, watch_folder)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) -> Result<Session> {
|
||||
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
|
||||
ctx.set_config_internal(
|
||||
Config::IsChatmail,
|
||||
crate::config::from_bool(session.is_chatmail()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let folder = imap.folder.clone();
|
||||
// Update quota no more than once a minute.
|
||||
if ctx.quota_needs_update(session.transport_id(), 60).await
|
||||
&& let Err(err) = ctx.update_recent_quota(&mut session).await
|
||||
&& let Err(err) = ctx.update_recent_quota(&mut session, folder).await
|
||||
{
|
||||
warn!(ctx, "Failed to update quota: {:#}.", err);
|
||||
}
|
||||
@@ -479,7 +437,7 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
|
||||
.await
|
||||
.context("Failed to register push token")?;
|
||||
|
||||
let session = fetch_idle(ctx, imap, session, FolderMeaning::Inbox).await?;
|
||||
let session = fetch_idle(ctx, imap, session).await?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@@ -488,32 +446,17 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
|
||||
/// This function performs all IMAP operations on a single folder, selecting it if necessary and
|
||||
/// handling all the errors. In case of an error, an error is returned and connection is dropped,
|
||||
/// otherwise connection is returned.
|
||||
async fn fetch_idle(
|
||||
ctx: &Context,
|
||||
connection: &mut Imap,
|
||||
mut session: Session,
|
||||
folder_meaning: FolderMeaning,
|
||||
) -> Result<Session> {
|
||||
let Some((folder_config, watch_folder)) = convert_folder_meaning(ctx, folder_meaning).await?
|
||||
else {
|
||||
// The folder is not configured.
|
||||
// For example, this happens if the server does not have Sent folder
|
||||
// but watching Sent folder is enabled.
|
||||
connection.connectivity.set_not_configured(ctx);
|
||||
connection.idle_interrupt_receiver.recv().await.ok();
|
||||
bail!("Cannot fetch folder {folder_meaning} because it is not configured");
|
||||
};
|
||||
async fn fetch_idle(ctx: &Context, connection: &mut Imap, mut session: Session) -> Result<Session> {
|
||||
let watch_folder = connection.folder.clone();
|
||||
|
||||
if folder_config == Config::ConfiguredInboxFolder {
|
||||
session
|
||||
.store_seen_flags_on_imap(ctx)
|
||||
.await
|
||||
.context("store_seen_flags_on_imap")?;
|
||||
}
|
||||
session
|
||||
.store_seen_flags_on_imap(ctx)
|
||||
.await
|
||||
.context("store_seen_flags_on_imap")?;
|
||||
|
||||
// Fetch the watched folder.
|
||||
connection
|
||||
.fetch_move_delete(ctx, &mut session, &watch_folder, folder_meaning)
|
||||
.fetch_move_delete(ctx, &mut session, &watch_folder)
|
||||
.await
|
||||
.context("fetch_move_delete")?;
|
||||
|
||||
@@ -547,7 +490,7 @@ async fn fetch_idle(
|
||||
ctx,
|
||||
"IMAP session does not support IDLE, going to fake idle."
|
||||
);
|
||||
connection.fake_idle(ctx, watch_folder).await?;
|
||||
connection.fake_idle(ctx, &watch_folder).await?;
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
@@ -559,7 +502,7 @@ async fn fetch_idle(
|
||||
.unwrap_or_default()
|
||||
{
|
||||
info!(ctx, "IMAP IDLE is disabled, going to fake idle.");
|
||||
connection.fake_idle(ctx, watch_folder).await?;
|
||||
connection.fake_idle(ctx, &watch_folder).await?;
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
@@ -579,73 +522,6 @@ async fn fetch_idle(
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Simplified IMAP loop to watch non-inbox folders.
|
||||
async fn simple_imap_loop(
|
||||
ctx: Context,
|
||||
started: oneshot::Sender<()>,
|
||||
inbox_handlers: ImapConnectionHandlers,
|
||||
folder_meaning: FolderMeaning,
|
||||
) {
|
||||
use futures::future::FutureExt;
|
||||
|
||||
info!(ctx, "Starting simple loop for {folder_meaning}.");
|
||||
let ImapConnectionHandlers {
|
||||
mut connection,
|
||||
stop_token,
|
||||
} = inbox_handlers;
|
||||
|
||||
let ctx1 = ctx.clone();
|
||||
|
||||
let fut = async move {
|
||||
let ctx = ctx1;
|
||||
if let Err(()) = started.send(()) {
|
||||
warn!(
|
||||
ctx,
|
||||
"Simple imap loop for {folder_meaning}, missing started receiver."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut old_session: Option<Session> = None;
|
||||
loop {
|
||||
let session = if let Some(session) = old_session.take() {
|
||||
session
|
||||
} else {
|
||||
info!(ctx, "Preparing new IMAP session for {folder_meaning}.");
|
||||
match connection.prepare(&ctx).await {
|
||||
Err(err) => {
|
||||
warn!(
|
||||
ctx,
|
||||
"Failed to prepare {folder_meaning} connection: {err:#}."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(session) => session,
|
||||
}
|
||||
};
|
||||
|
||||
match fetch_idle(&ctx, &mut connection, session, folder_meaning).await {
|
||||
Err(err) => warn!(ctx, "Failed fetch_idle: {err:#}"),
|
||||
Ok(session) => {
|
||||
info!(
|
||||
ctx,
|
||||
"IMAP loop iteration for {folder_meaning} finished, keeping the session"
|
||||
);
|
||||
old_session = Some(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
stop_token
|
||||
.cancelled()
|
||||
.map(|_| {
|
||||
info!(ctx, "Shutting down IMAP loop for {folder_meaning}.");
|
||||
})
|
||||
.race(fut)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn smtp_loop(
|
||||
ctx: Context,
|
||||
started: oneshot::Sender<()>,
|
||||
@@ -748,7 +624,6 @@ impl Scheduler {
|
||||
let (location_interrupt_send, location_interrupt_recv) = channel::bounded(1);
|
||||
|
||||
let mut inboxes = Vec::new();
|
||||
let mut oboxes = Vec::new();
|
||||
let mut start_recvs = Vec::new();
|
||||
|
||||
for (transport_id, configured_login_param) in ConfiguredLoginParam::load_all(ctx).await? {
|
||||
@@ -760,30 +635,17 @@ impl Scheduler {
|
||||
task::spawn(inbox_loop(ctx, inbox_start_send, inbox_handlers))
|
||||
};
|
||||
let addr = configured_login_param.addr.clone();
|
||||
let folder = configured_login_param
|
||||
.imap_folder
|
||||
.unwrap_or_else(|| "INBOX".to_string());
|
||||
let inbox = SchedBox {
|
||||
addr: addr.clone(),
|
||||
meaning: FolderMeaning::Inbox,
|
||||
folder,
|
||||
conn_state,
|
||||
handle,
|
||||
};
|
||||
inboxes.push(inbox);
|
||||
start_recvs.push(inbox_start_recv);
|
||||
|
||||
if ctx.should_watch_mvbox().await? {
|
||||
let (conn_state, handlers) =
|
||||
ImapConnectionState::new(ctx, transport_id, configured_login_param).await?;
|
||||
let (start_send, start_recv) = oneshot::channel();
|
||||
let ctx = ctx.clone();
|
||||
let meaning = FolderMeaning::Mvbox;
|
||||
let handle = task::spawn(simple_imap_loop(ctx, start_send, handlers, meaning));
|
||||
oboxes.push(SchedBox {
|
||||
addr,
|
||||
meaning,
|
||||
conn_state,
|
||||
handle,
|
||||
});
|
||||
start_recvs.push(start_recv);
|
||||
}
|
||||
}
|
||||
|
||||
let smtp_handle = {
|
||||
@@ -810,7 +672,6 @@ impl Scheduler {
|
||||
|
||||
let res = Self {
|
||||
inboxes,
|
||||
oboxes,
|
||||
smtp,
|
||||
smtp_handle,
|
||||
ephemeral_handle,
|
||||
@@ -830,7 +691,7 @@ impl Scheduler {
|
||||
}
|
||||
|
||||
fn boxes(&self) -> impl Iterator<Item = &SchedBox> {
|
||||
self.inboxes.iter().chain(self.oboxes.iter())
|
||||
self.inboxes.iter()
|
||||
}
|
||||
|
||||
fn maybe_network(&self) {
|
||||
@@ -884,7 +745,7 @@ impl Scheduler {
|
||||
let timeout_duration = std::time::Duration::from_secs(30);
|
||||
|
||||
let tracker = TaskTracker::new();
|
||||
for b in self.inboxes.into_iter().chain(self.oboxes.into_iter()) {
|
||||
for b in self.inboxes {
|
||||
let context = context.clone();
|
||||
tracker.spawn(async move {
|
||||
tokio::time::timeout(timeout_duration, b.handle)
|
||||
|
||||
@@ -5,11 +5,10 @@ use std::{iter::once, ops::Deref, sync::Arc};
|
||||
use anyhow::Result;
|
||||
use humansize::{BINARY, format_size};
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::imap::{FolderMeaning, get_watched_folder_configs};
|
||||
use crate::quota::{QUOTA_ERROR_THRESHOLD_PERCENTAGE, QUOTA_WARN_THRESHOLD_PERCENTAGE};
|
||||
use crate::stock_str;
|
||||
use crate::{context::Context, log::LogExt};
|
||||
|
||||
use super::InnerSchedulerState;
|
||||
|
||||
@@ -67,40 +66,33 @@ enum DetailedConnectivity {
|
||||
|
||||
/// Connection is established and is idle.
|
||||
Idle,
|
||||
|
||||
/// The folder was configured not to be watched or configured_*_folder is not set
|
||||
NotConfigured,
|
||||
}
|
||||
|
||||
impl DetailedConnectivity {
|
||||
fn to_basic(&self) -> Option<Connectivity> {
|
||||
fn to_basic(&self) -> Connectivity {
|
||||
match self {
|
||||
DetailedConnectivity::Error(_) => Some(Connectivity::NotConnected),
|
||||
DetailedConnectivity::Uninitialized => Some(Connectivity::NotConnected),
|
||||
DetailedConnectivity::Connecting => Some(Connectivity::Connecting),
|
||||
DetailedConnectivity::Working => Some(Connectivity::Working),
|
||||
DetailedConnectivity::InterruptingIdle => Some(Connectivity::Working),
|
||||
DetailedConnectivity::Error(_) => Connectivity::NotConnected,
|
||||
DetailedConnectivity::Uninitialized => Connectivity::NotConnected,
|
||||
DetailedConnectivity::Connecting => Connectivity::Connecting,
|
||||
DetailedConnectivity::Working => Connectivity::Working,
|
||||
DetailedConnectivity::InterruptingIdle => Connectivity::Working,
|
||||
|
||||
// At this point IMAP has just connected,
|
||||
// but does not know yet if there are messages to download.
|
||||
// We still convert this to Working state
|
||||
// so user can see "Updating..." and not "Connected"
|
||||
// which is reserved for idle state.
|
||||
DetailedConnectivity::Preparing => Some(Connectivity::Working),
|
||||
DetailedConnectivity::Preparing => Connectivity::Working,
|
||||
|
||||
// Just don't return a connectivity, probably the folder is configured not to be
|
||||
// watched, so we are not interested in it.
|
||||
DetailedConnectivity::NotConfigured => None,
|
||||
|
||||
DetailedConnectivity::Idle => Some(Connectivity::Connected),
|
||||
DetailedConnectivity::Idle => Connectivity::Connected,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_icon(&self) -> String {
|
||||
match self {
|
||||
DetailedConnectivity::Error(_)
|
||||
| DetailedConnectivity::Uninitialized
|
||||
| DetailedConnectivity::NotConfigured => "<span class=\"red dot\"></span>".to_string(),
|
||||
DetailedConnectivity::Error(_) | DetailedConnectivity::Uninitialized => {
|
||||
"<span class=\"red dot\"></span>".to_string()
|
||||
}
|
||||
DetailedConnectivity::Connecting => "<span class=\"yellow dot\"></span>".to_string(),
|
||||
DetailedConnectivity::Preparing
|
||||
| DetailedConnectivity::Working
|
||||
@@ -120,7 +112,6 @@ impl DetailedConnectivity {
|
||||
DetailedConnectivity::InterruptingIdle | DetailedConnectivity::Idle => {
|
||||
stock_str::connected(context).await
|
||||
}
|
||||
DetailedConnectivity::NotConfigured => "Not configured".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +130,6 @@ impl DetailedConnectivity {
|
||||
DetailedConnectivity::InterruptingIdle
|
||||
| DetailedConnectivity::Preparing
|
||||
| DetailedConnectivity::Idle => stock_str::last_msg_sent_successfully(context).await,
|
||||
DetailedConnectivity::NotConfigured => "Not configured".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +141,6 @@ impl DetailedConnectivity {
|
||||
DetailedConnectivity::Working => false,
|
||||
DetailedConnectivity::InterruptingIdle => false,
|
||||
DetailedConnectivity::Preparing => false, // Just connected, there may still be work to do.
|
||||
DetailedConnectivity::NotConfigured => true,
|
||||
DetailedConnectivity::Idle => true,
|
||||
}
|
||||
}
|
||||
@@ -180,9 +169,6 @@ impl ConnectivityStore {
|
||||
pub(crate) fn set_preparing(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::Preparing);
|
||||
}
|
||||
pub(crate) fn set_not_configured(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::NotConfigured);
|
||||
}
|
||||
pub(crate) fn set_idle(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::Idle);
|
||||
}
|
||||
@@ -190,7 +176,7 @@ impl ConnectivityStore {
|
||||
fn get_detailed(&self) -> DetailedConnectivity {
|
||||
self.0.lock().deref().clone()
|
||||
}
|
||||
fn get_basic(&self) -> Option<Connectivity> {
|
||||
fn get_basic(&self) -> Connectivity {
|
||||
self.0.lock().to_basic()
|
||||
}
|
||||
fn get_all_work_done(&self) -> bool {
|
||||
@@ -201,27 +187,14 @@ impl ConnectivityStore {
|
||||
/// Set all folder states to InterruptingIdle in case they were `Idle` before.
|
||||
/// Called during `dc_maybe_network()` to make sure that `all_work_done()`
|
||||
/// returns false immediately after `dc_maybe_network()`.
|
||||
pub(crate) fn idle_interrupted(inboxes: Vec<ConnectivityStore>, oboxes: Vec<ConnectivityStore>) {
|
||||
pub(crate) fn idle_interrupted(inboxes: Vec<ConnectivityStore>) {
|
||||
for inbox in inboxes {
|
||||
let mut connectivity_lock = inbox.0.lock();
|
||||
// For the inbox, we also have to set the connectivity to InterruptingIdle if it was
|
||||
// NotConfigured before: If all folders are NotConfigured, dc_get_connectivity()
|
||||
// returns Connected. But after dc_maybe_network(), dc_get_connectivity() must not
|
||||
// return Connected until DC is completely done with fetching folders; this also
|
||||
// includes scan_folders() which happens on the inbox thread.
|
||||
if *connectivity_lock == DetailedConnectivity::Idle
|
||||
|| *connectivity_lock == DetailedConnectivity::NotConfigured
|
||||
{
|
||||
*connectivity_lock = DetailedConnectivity::InterruptingIdle;
|
||||
}
|
||||
}
|
||||
|
||||
for state in oboxes {
|
||||
let mut connectivity_lock = state.0.lock();
|
||||
if *connectivity_lock == DetailedConnectivity::Idle {
|
||||
*connectivity_lock = DetailedConnectivity::InterruptingIdle;
|
||||
}
|
||||
}
|
||||
|
||||
// No need to send ConnectivityChanged, the user-facing connectivity doesn't change because
|
||||
// of what we do here.
|
||||
}
|
||||
@@ -234,9 +207,7 @@ pub(crate) fn maybe_network_lost(context: &Context, stores: Vec<ConnectivityStor
|
||||
let mut connectivity_lock = store.0.lock();
|
||||
if !matches!(
|
||||
*connectivity_lock,
|
||||
DetailedConnectivity::Uninitialized
|
||||
| DetailedConnectivity::Error(_)
|
||||
| DetailedConnectivity::NotConfigured,
|
||||
DetailedConnectivity::Uninitialized | DetailedConnectivity::Error(_)
|
||||
) {
|
||||
*connectivity_lock = DetailedConnectivity::Error("Connection lost".to_string());
|
||||
}
|
||||
@@ -273,9 +244,8 @@ impl Context {
|
||||
let stores = self.connectivities.lock().clone();
|
||||
let mut connectivities = Vec::new();
|
||||
for s in stores {
|
||||
if let Some(connectivity) = s.get_basic() {
|
||||
connectivities.push(connectivity);
|
||||
}
|
||||
let connectivity = s.get_basic();
|
||||
connectivities.push(connectivity);
|
||||
}
|
||||
connectivities
|
||||
.into_iter()
|
||||
@@ -386,7 +356,7 @@ impl Context {
|
||||
.map(|b| {
|
||||
(
|
||||
b.addr.clone(),
|
||||
b.meaning,
|
||||
b.folder.clone(),
|
||||
b.conn_state.state.connectivity.clone(),
|
||||
)
|
||||
})
|
||||
@@ -411,7 +381,6 @@ impl Context {
|
||||
// [======67%===== ]
|
||||
// =============================================================================================
|
||||
|
||||
let watched_folders = get_watched_folder_configs(self).await?;
|
||||
let incoming_messages = stock_str::incoming_messages(self).await;
|
||||
ret += &format!("<h3>{incoming_messages}</h3><ul>");
|
||||
|
||||
@@ -433,41 +402,14 @@ impl Context {
|
||||
let folders = folders_states
|
||||
.iter()
|
||||
.filter(|(folder_addr, ..)| *folder_addr == transport_addr);
|
||||
for (_addr, folder, state) in folders {
|
||||
let mut folder_added = false;
|
||||
|
||||
if let Some(config) = folder.to_config().filter(|c| watched_folders.contains(c)) {
|
||||
let f = self.get_config(config).await.log_err(self).ok().flatten();
|
||||
|
||||
if let Some(foldername) = f {
|
||||
let detailed = &state.get_detailed();
|
||||
ret += &*detailed.to_icon();
|
||||
ret += " <b>";
|
||||
if folder == &FolderMeaning::Inbox {
|
||||
ret += &*domain_escaped;
|
||||
} else {
|
||||
ret += &*escaper::encode_minimal(&foldername);
|
||||
}
|
||||
ret += ":</b> ";
|
||||
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self).await);
|
||||
ret += "<br />";
|
||||
|
||||
folder_added = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !folder_added && folder == &FolderMeaning::Inbox {
|
||||
let detailed = &state.get_detailed();
|
||||
if let DetailedConnectivity::Error(_) = detailed {
|
||||
// On the inbox thread, we also do some other things like scan_folders and run jobs
|
||||
// so, maybe, the inbox is not watched, but something else went wrong
|
||||
|
||||
ret += &*detailed.to_icon();
|
||||
ret += " ";
|
||||
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self).await);
|
||||
ret += "<br />";
|
||||
}
|
||||
}
|
||||
for (_addr, _folder, state) in folders {
|
||||
let detailed = &state.get_detailed();
|
||||
ret += &*detailed.to_icon();
|
||||
ret += " <b>";
|
||||
ret += &*domain_escaped;
|
||||
ret += ":</b> ";
|
||||
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self).await);
|
||||
ret += "<br />";
|
||||
}
|
||||
|
||||
let Some(quota) = quota.get(&transport_id) else {
|
||||
|
||||
@@ -17,17 +17,17 @@ use crate::context::Context;
|
||||
use crate::e2ee::ensure_secret_key_exists;
|
||||
use crate::events::EventType;
|
||||
use crate::headerdef::HeaderDef;
|
||||
use crate::key::{DcKey, Fingerprint, load_self_public_key};
|
||||
use crate::key::{DcKey, Fingerprint, load_self_public_key, self_fingerprint};
|
||||
use crate::log::LogExt as _;
|
||||
use crate::log::warn;
|
||||
use crate::message::{Message, Viewtype};
|
||||
use crate::message::{self, Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::param::Param;
|
||||
use crate::qr::check_qr;
|
||||
use crate::securejoin::bob::JoinerProgress;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::{create_id, time};
|
||||
use crate::{SecurejoinSource, stats};
|
||||
use crate::tools::{create_id, create_outgoing_rfc724_mid, time};
|
||||
use crate::{SecurejoinSource, mimefactory, stats};
|
||||
use crate::{SecurejoinUiPath, token};
|
||||
|
||||
mod bob;
|
||||
@@ -127,9 +127,6 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
|
||||
None => None,
|
||||
};
|
||||
let grpid = chat.as_ref().map(|c| c.grpid.as_str());
|
||||
let sync_token = token::lookup(context, Namespace::InviteNumber, grpid)
|
||||
.await?
|
||||
.is_none();
|
||||
// Invite number is used to request the inviter key.
|
||||
let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, grpid).await?;
|
||||
|
||||
@@ -156,12 +153,10 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
|
||||
.unwrap_or_default();
|
||||
|
||||
let qr = if let Some(chat) = chat {
|
||||
if sync_token {
|
||||
context
|
||||
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
|
||||
.await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
}
|
||||
context
|
||||
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
|
||||
.await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
|
||||
let chat_name = chat.get_name();
|
||||
let chat_name_shortened = shorten_name(chat_name, 25);
|
||||
@@ -178,11 +173,11 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
|
||||
if chat.typ == Chattype::OutBroadcast {
|
||||
// For historic reansons, broadcasts currently use j instead of i for the invitenumber.
|
||||
format!(
|
||||
"https://i.delta.chat/#{fingerprint}&x={grpid}&j={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&b={chat_name_urlencoded}",
|
||||
"https://i.delta.chat/#{fingerprint}&v=3&x={grpid}&j={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&b={chat_name_urlencoded}",
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"https://i.delta.chat/#{fingerprint}&x={grpid}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&g={chat_name_urlencoded}",
|
||||
"https://i.delta.chat/#{fingerprint}&v=3&x={grpid}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&g={chat_name_urlencoded}",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -190,12 +185,12 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
|
||||
let self_name_urlencoded = utf8_percent_encode(&self_name_shortened, DISALLOWED_CHARACTERS)
|
||||
.to_string()
|
||||
.replace("%20", "+");
|
||||
if sync_token {
|
||||
context.sync_qr_code_tokens(None).await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
}
|
||||
|
||||
context.sync_qr_code_tokens(None).await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
|
||||
format!(
|
||||
"https://i.delta.chat/#{fingerprint}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}",
|
||||
"https://i.delta.chat/#{fingerprint}&v=3&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}",
|
||||
)
|
||||
};
|
||||
|
||||
@@ -309,7 +304,9 @@ async fn verify_sender_by_fingerprint(
|
||||
fingerprint: &Fingerprint,
|
||||
contact_id: ContactId,
|
||||
) -> Result<bool> {
|
||||
let contact = Contact::get_by_id(context, contact_id).await?;
|
||||
let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let is_verified = contact.fingerprint().is_some_and(|fp| &fp == fingerprint);
|
||||
if is_verified {
|
||||
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
|
||||
@@ -346,12 +343,18 @@ pub(crate) enum HandshakeMessage {
|
||||
/// Step of Secure-Join protocol.
|
||||
#[derive(Debug, Display, PartialEq, Eq)]
|
||||
pub(crate) enum SecureJoinStep {
|
||||
/// vc-request or vg-request
|
||||
/// vc-request or vg-request; only used in legacy securejoin
|
||||
Request { invitenumber: String },
|
||||
|
||||
/// vc-auth-required or vg-auth-required
|
||||
/// vc-auth-required or vg-auth-required; only used in legacy securejoin
|
||||
AuthRequired,
|
||||
|
||||
/// vc-request-pubkey; only used in securejoin v3
|
||||
RequestPubkey,
|
||||
|
||||
/// vc-pubkey; only used in securejoin v3
|
||||
Pubkey,
|
||||
|
||||
/// vc-request-with-auth or vg-request-with-auth
|
||||
RequestWithAuth,
|
||||
|
||||
@@ -381,6 +384,8 @@ pub(crate) fn get_secure_join_step(mime_message: &MimeMessage) -> Option<SecureJ
|
||||
})
|
||||
} else if let Some(step) = mime_message.get_header(HeaderDef::SecureJoin) {
|
||||
match step {
|
||||
"vc-request-pubkey" => Some(SecureJoinStep::RequestPubkey),
|
||||
"vc-pubkey" => Some(SecureJoinStep::Pubkey),
|
||||
"vg-auth-required" | "vc-auth-required" => Some(SecureJoinStep::AuthRequired),
|
||||
"vg-request-with-auth" | "vc-request-with-auth" => {
|
||||
Some(SecureJoinStep::RequestWithAuth)
|
||||
@@ -439,7 +444,10 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
// will improve security (completely unrelated to the securejoin protocol)
|
||||
// and is something we want to do in the future:
|
||||
// https://www.rfc-editor.org/rfc/rfc9580.html#name-surreptitious-forwarding
|
||||
if !matches!(step, SecureJoinStep::Request { .. }) {
|
||||
if !matches!(
|
||||
step,
|
||||
SecureJoinStep::Request { .. } | SecureJoinStep::RequestPubkey | SecureJoinStep::Pubkey
|
||||
) {
|
||||
let mut self_found = false;
|
||||
let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint();
|
||||
for (addr, key) in &mime_message.gossiped_keys {
|
||||
@@ -505,7 +513,57 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
==== Bob - the joiner's side =====
|
||||
==== Step 4 in "Setup verified contact" protocol =====
|
||||
========================================================*/
|
||||
bob::handle_auth_required(context, mime_message).await
|
||||
bob::handle_auth_required_or_pubkey(context, mime_message).await
|
||||
}
|
||||
SecureJoinStep::RequestPubkey => {
|
||||
/*========================================================
|
||||
==== Alice - the inviter's side =====
|
||||
==== Bob requests our public key (Securejoin v3) =====
|
||||
========================================================*/
|
||||
|
||||
debug_assert!(
|
||||
mime_message.signature.is_none(),
|
||||
"RequestPubkey is not supposed to be signed"
|
||||
);
|
||||
let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else {
|
||||
warn!(
|
||||
context,
|
||||
"Ignoring {step} message because of missing auth code."
|
||||
);
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
};
|
||||
if !token::exists(context, token::Namespace::Auth, auth).await? {
|
||||
warn!(context, "Secure-join denied (bad auth).");
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
}
|
||||
|
||||
let rfc724_mid = create_outgoing_rfc724_mid();
|
||||
let addr = ContactAddress::new(&mime_message.from.addr)?;
|
||||
let attach_self_pubkey = true;
|
||||
let self_fp = self_fingerprint(context).await?;
|
||||
let shared_secret = format!("securejoin/{self_fp}/{auth}");
|
||||
let rendered_message = mimefactory::render_symm_encrypted_securejoin_message(
|
||||
context,
|
||||
"vc-pubkey",
|
||||
&rfc724_mid,
|
||||
attach_self_pubkey,
|
||||
auth,
|
||||
&shared_secret,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let msg_id = message::insert_tombstone(context, &rfc724_mid).await?;
|
||||
insert_into_smtp(context, &rfc724_mid, &addr, rendered_message, msg_id).await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
|
||||
Ok(HandshakeMessage::Done)
|
||||
}
|
||||
SecureJoinStep::Pubkey => {
|
||||
/*========================================================
|
||||
==== Bob - the joiner's side =====
|
||||
==== Alice sent us her pubkey (Securejoin v3) =====
|
||||
========================================================*/
|
||||
bob::handle_auth_required_or_pubkey(context, mime_message).await
|
||||
}
|
||||
SecureJoinStep::RequestWithAuth => {
|
||||
/*==========================================================
|
||||
@@ -586,15 +644,12 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
|
||||
}
|
||||
contact_id.regossip_keys(context).await?;
|
||||
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?;
|
||||
// for setup-contact, make Alice's one-to-one chat with Bob visible
|
||||
// (secure-join-information are shown in the group chat)
|
||||
if grpid.is_empty() {
|
||||
ChatId::create_for_contact(context, contact_id).await?;
|
||||
}
|
||||
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
|
||||
if let Some(joining_chat_id) = joining_chat_id {
|
||||
// Join group.
|
||||
chat::add_contact_to_chat_ex(context, Nosync, joining_chat_id, contact_id, true)
|
||||
.await?;
|
||||
|
||||
@@ -604,6 +659,10 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
// We don't use the membership consistency algorithm for broadcast channels,
|
||||
// so, sync the memberlist when adding a contact
|
||||
chat.sync_contacts(context).await.log_err(context).ok();
|
||||
} else {
|
||||
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited)
|
||||
.await?;
|
||||
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
|
||||
}
|
||||
|
||||
inviter_progress(context, contact_id, joining_chat_id, chat.typ)?;
|
||||
@@ -666,6 +725,24 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_into_smtp(
|
||||
context: &Context,
|
||||
rfc724_mid: &str,
|
||||
recipient: &str,
|
||||
rendered_message: String,
|
||||
msg_id: MsgId,
|
||||
) -> Result<(), Error> {
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
(&rfc724_mid, &recipient, &rendered_message, msg_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Observe self-sent Securejoin message.
|
||||
///
|
||||
/// In a multi-device-setup, there may be other devices that "see" the handshake messages.
|
||||
@@ -697,6 +774,8 @@ pub(crate) async fn observe_securejoin_on_other_device(
|
||||
match step {
|
||||
SecureJoinStep::Request { .. }
|
||||
| SecureJoinStep::AuthRequired
|
||||
| SecureJoinStep::RequestPubkey
|
||||
| SecureJoinStep::Pubkey
|
||||
| SecureJoinStep::Deprecated
|
||||
| SecureJoinStep::Unknown { .. } => {
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
|
||||
@@ -5,20 +5,22 @@ use anyhow::{Context as _, Result};
|
||||
use super::HandshakeMessage;
|
||||
use super::qrinvite::QrInvite;
|
||||
use crate::chat::{self, ChatId, is_contact_in_chat};
|
||||
use crate::chatlist_events;
|
||||
use crate::constants::{Blocked, Chattype};
|
||||
use crate::contact::Origin;
|
||||
use crate::contact::{Contact, Origin};
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::key::self_fingerprint;
|
||||
use crate::log::LogExt;
|
||||
use crate::message::{Message, MsgId, Viewtype};
|
||||
use crate::message::{self, Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::param::{Param, Params};
|
||||
use crate::securejoin::{ContactId, encrypted_and_signed, verify_sender_by_fingerprint};
|
||||
use crate::securejoin::{
|
||||
ContactId, encrypted_and_signed, insert_into_smtp, verify_sender_by_fingerprint,
|
||||
};
|
||||
use crate::stock_str;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::{smeared_time, time};
|
||||
use crate::tools::{create_outgoing_rfc724_mid, smeared_time, time};
|
||||
use crate::{chatlist_events, mimefactory};
|
||||
|
||||
/// Starts the securejoin protocol with the QR `invite`.
|
||||
///
|
||||
@@ -47,8 +49,14 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
// receive_imf.
|
||||
let private_chat_id = private_chat_id(context, &invite).await?;
|
||||
|
||||
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?;
|
||||
context.emit_event(EventType::ContactsChanged(None));
|
||||
match invite {
|
||||
QrInvite::Group { .. } | QrInvite::Contact { .. } => {
|
||||
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined)
|
||||
.await?;
|
||||
context.emit_event(EventType::ContactsChanged(None));
|
||||
}
|
||||
QrInvite::Broadcast { .. } => {}
|
||||
}
|
||||
|
||||
let has_key = context
|
||||
.sql
|
||||
@@ -213,11 +221,11 @@ LIMIT 1
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles `vc-auth-required` and `vg-auth-required` handshake messages.
|
||||
/// Handles `vc-auth-required`, `vg-auth-required`, and `vc-pubkey` handshake messages.
|
||||
///
|
||||
/// # Bob - the joiner's side
|
||||
/// ## Step 4 in the "Setup Contact protocol"
|
||||
pub(super) async fn handle_auth_required(
|
||||
pub(super) async fn handle_auth_required_or_pubkey(
|
||||
context: &Context,
|
||||
message: &MimeMessage,
|
||||
) -> Result<HandshakeMessage> {
|
||||
@@ -299,47 +307,72 @@ pub(crate) async fn send_handshake_message(
|
||||
chat_id: ChatId,
|
||||
step: BobHandshakeMsg,
|
||||
) -> Result<()> {
|
||||
let mut msg = Message {
|
||||
viewtype: Viewtype::Text,
|
||||
text: step.body_text(invite),
|
||||
hidden: true,
|
||||
..Default::default()
|
||||
};
|
||||
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
|
||||
if invite.is_v3() && matches!(step, BobHandshakeMsg::Request) {
|
||||
// Send a minimal symmetrically-encrypted vc-request-pubkey message
|
||||
let rfc724_mid = create_outgoing_rfc724_mid();
|
||||
let contact = Contact::get_by_id(context, invite.contact_id()).await?;
|
||||
let recipient = contact.get_addr();
|
||||
let alice_fp = invite.fingerprint().hex();
|
||||
let auth = invite.authcode();
|
||||
let shared_secret = format!("securejoin/{alice_fp}/{auth}");
|
||||
let attach_self_pubkey = false;
|
||||
let rendered_message = mimefactory::render_symm_encrypted_securejoin_message(
|
||||
context,
|
||||
"vc-request-pubkey",
|
||||
&rfc724_mid,
|
||||
attach_self_pubkey,
|
||||
auth,
|
||||
&shared_secret,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Sends the step in Secure-Join header.
|
||||
msg.param.set(Param::Arg, step.securejoin_header(invite));
|
||||
let msg_id = message::insert_tombstone(context, &rfc724_mid).await?;
|
||||
insert_into_smtp(context, &rfc724_mid, recipient, rendered_message, msg_id).await?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
} else {
|
||||
let mut msg = Message {
|
||||
viewtype: Viewtype::Text,
|
||||
text: step.body_text(invite),
|
||||
hidden: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match step {
|
||||
BobHandshakeMsg::Request => {
|
||||
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
|
||||
msg.param.set(Param::Arg2, invite.invitenumber());
|
||||
msg.force_plaintext();
|
||||
}
|
||||
BobHandshakeMsg::RequestWithAuth => {
|
||||
// Sends the Secure-Join-Auth header in mimefactory.rs.
|
||||
msg.param.set(Param::Arg2, invite.authcode());
|
||||
msg.param.set_int(Param::GuaranteeE2ee, 1);
|
||||
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
|
||||
|
||||
// Sends our own fingerprint in the Secure-Join-Fingerprint header.
|
||||
let bob_fp = self_fingerprint(context).await?;
|
||||
msg.param.set(Param::Arg3, bob_fp);
|
||||
// Sends the step in Secure-Join header.
|
||||
msg.param.set(Param::Arg, step.securejoin_header(invite));
|
||||
|
||||
// Sends the grpid in the Secure-Join-Group header.
|
||||
//
|
||||
// `Secure-Join-Group` header is deprecated,
|
||||
// but old Delta Chat core requires that Alice receives it.
|
||||
//
|
||||
// Previous Delta Chat core also sent `Secure-Join-Group` header
|
||||
// in `vg-request` messages,
|
||||
// but it was not used on the receiver.
|
||||
if let QrInvite::Group { grpid, .. } = invite {
|
||||
msg.param.set(Param::Arg4, grpid);
|
||||
match step {
|
||||
BobHandshakeMsg::Request => {
|
||||
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
|
||||
msg.param.set(Param::Arg2, invite.invitenumber());
|
||||
msg.force_plaintext();
|
||||
}
|
||||
}
|
||||
};
|
||||
BobHandshakeMsg::RequestWithAuth => {
|
||||
// Sends the Secure-Join-Auth header in mimefactory.rs.
|
||||
msg.param.set(Param::Arg2, invite.authcode());
|
||||
msg.param.set_int(Param::GuaranteeE2ee, 1);
|
||||
|
||||
chat::send_msg(context, chat_id, &mut msg).await?;
|
||||
// Sends our own fingerprint in the Secure-Join-Fingerprint header.
|
||||
let bob_fp = self_fingerprint(context).await?;
|
||||
msg.param.set(Param::Arg3, bob_fp);
|
||||
|
||||
// Sends the grpid in the Secure-Join-Group header.
|
||||
//
|
||||
// `Secure-Join-Group` header is deprecated,
|
||||
// but old Delta Chat core requires that Alice receives it.
|
||||
//
|
||||
// Previous Delta Chat core also sent `Secure-Join-Group` header
|
||||
// in `vg-request` messages,
|
||||
// but it was not used on the receiver.
|
||||
if let QrInvite::Group { grpid, .. } = invite {
|
||||
msg.param.set(Param::Arg4, grpid);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chat::send_msg(context, chat_id, &mut msg).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::qr::Qr;
|
||||
|
||||
/// Represents the data from a QR-code scan.
|
||||
///
|
||||
/// There are methods to conveniently access fields present in both variants.
|
||||
/// There are methods to conveniently access fields present in all three variants.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub enum QrInvite {
|
||||
Contact {
|
||||
@@ -20,6 +20,8 @@ pub enum QrInvite {
|
||||
fingerprint: Fingerprint,
|
||||
invitenumber: String,
|
||||
authcode: String,
|
||||
#[serde(default)]
|
||||
is_v3: bool,
|
||||
},
|
||||
Group {
|
||||
contact_id: ContactId,
|
||||
@@ -28,6 +30,8 @@ pub enum QrInvite {
|
||||
grpid: String,
|
||||
invitenumber: String,
|
||||
authcode: String,
|
||||
#[serde(default)]
|
||||
is_v3: bool,
|
||||
},
|
||||
Broadcast {
|
||||
contact_id: ContactId,
|
||||
@@ -36,6 +40,8 @@ pub enum QrInvite {
|
||||
grpid: String,
|
||||
invitenumber: String,
|
||||
authcode: String,
|
||||
#[serde(default)]
|
||||
is_v3: bool,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -78,6 +84,14 @@ impl QrInvite {
|
||||
| Self::Broadcast { authcode, .. } => authcode,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_v3(&self) -> bool {
|
||||
match *self {
|
||||
QrInvite::Contact { is_v3, .. } => is_v3,
|
||||
QrInvite::Group { is_v3, .. } => is_v3,
|
||||
QrInvite::Broadcast { is_v3, .. } => is_v3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Qr> for QrInvite {
|
||||
@@ -90,11 +104,13 @@ impl TryFrom<Qr> for QrInvite {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
} => Ok(QrInvite::Contact {
|
||||
contact_id,
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
}),
|
||||
Qr::AskVerifyGroup {
|
||||
grpname,
|
||||
@@ -103,6 +119,7 @@ impl TryFrom<Qr> for QrInvite {
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
} => Ok(QrInvite::Group {
|
||||
contact_id,
|
||||
fingerprint,
|
||||
@@ -110,6 +127,7 @@ impl TryFrom<Qr> for QrInvite {
|
||||
grpid,
|
||||
invitenumber,
|
||||
authcode,
|
||||
is_v3,
|
||||
}),
|
||||
Qr::AskJoinBroadcast {
|
||||
name,
|
||||
@@ -118,6 +136,7 @@ impl TryFrom<Qr> for QrInvite {
|
||||
fingerprint,
|
||||
authcode,
|
||||
invitenumber,
|
||||
is_v3,
|
||||
} => Ok(QrInvite::Broadcast {
|
||||
name,
|
||||
grpid,
|
||||
@@ -125,6 +144,7 @@ impl TryFrom<Qr> for QrInvite {
|
||||
fingerprint,
|
||||
authcode,
|
||||
invitenumber,
|
||||
is_v3,
|
||||
}),
|
||||
_ => bail!("Unsupported QR type"),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
use regex::Regex;
|
||||
|
||||
use super::*;
|
||||
use crate::chat::{CantSendReason, add_contact_to_chat, remove_contact_from_chat};
|
||||
@@ -11,10 +12,10 @@ use crate::key::self_fingerprint;
|
||||
use crate::mimeparser::{GossipedKey, SystemMessage};
|
||||
use crate::qr::Qr;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::stock_str::{self, messages_e2e_encrypted};
|
||||
use crate::stock_str::{self, messages_e2ee_info_msg};
|
||||
use crate::test_utils::{
|
||||
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
|
||||
TimeShiftFalsePositiveNote, get_chat_msg,
|
||||
TimeShiftFalsePositiveNote, get_chat_msg, sync,
|
||||
};
|
||||
use crate::tools::SystemTime;
|
||||
|
||||
@@ -27,7 +28,7 @@ enum SetupContactCase {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_setup_contact() {
|
||||
async fn test_setup_contact_basic() {
|
||||
test_setup_contact_ex(SetupContactCase::Normal).await
|
||||
}
|
||||
|
||||
@@ -62,13 +63,13 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
bob.set_config(Config::Displayname, Some("Bob Examplenet"))
|
||||
.await
|
||||
.unwrap();
|
||||
let alice_auto_submitted_hdr;
|
||||
let alice_auto_submitted_hdr: bool;
|
||||
match case {
|
||||
SetupContactCase::AliceIsBot => {
|
||||
alice.set_config_bool(Config::Bot, true).await.unwrap();
|
||||
alice_auto_submitted_hdr = "Auto-Submitted: auto-generated";
|
||||
alice_auto_submitted_hdr = true;
|
||||
}
|
||||
_ => alice_auto_submitted_hdr = "Auto-Submitted: auto-replied",
|
||||
_ => alice_auto_submitted_hdr = false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -108,7 +109,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
let mut i = 0..msg_cnt;
|
||||
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
|
||||
assert!(msg.is_info());
|
||||
assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await);
|
||||
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
|
||||
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
|
||||
assert!(msg.is_info());
|
||||
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await);
|
||||
@@ -118,12 +119,15 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
assert!(!sent.payload.contains("Bob Examplenet"));
|
||||
assert_eq!(sent.recipient(), EmailAddress::new(alice_addr).unwrap());
|
||||
let msg = alice.parse_msg(&sent).await;
|
||||
assert!(!msg.was_encrypted());
|
||||
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-request");
|
||||
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
|
||||
assert!(msg.signature.is_none());
|
||||
assert_eq!(
|
||||
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
||||
"vc-request-pubkey"
|
||||
);
|
||||
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
|
||||
assert!(!msg.header_exists(HeaderDef::AutoSubmitted));
|
||||
|
||||
tcm.section("Step 3: Alice receives vc-request, sends vc-auth-required");
|
||||
tcm.section("Step 3: Alice receives vc-request-pubkey, sends vc-pubkey");
|
||||
alice.recv_msg_trash(&sent).await;
|
||||
assert_eq!(
|
||||
Chatlist::try_load(&alice, 0, None, None)
|
||||
@@ -134,13 +138,14 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
);
|
||||
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
assert!(sent.payload.contains(alice_auto_submitted_hdr));
|
||||
assert_eq!(sent.payload.contains("Auto-Submitted:"), false);
|
||||
assert!(!sent.payload.contains("Alice Exampleorg"));
|
||||
let msg = bob.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-pubkey");
|
||||
assert_eq!(
|
||||
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
||||
"vc-auth-required"
|
||||
msg.get_header(HeaderDef::AutoSubmitted),
|
||||
alice_auto_submitted_hdr.then_some("auto-generated")
|
||||
);
|
||||
|
||||
let bob_chat = bob.get_chat(&alice).await;
|
||||
@@ -170,7 +175,6 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
|
||||
// Check Bob sent the right message.
|
||||
let sent = bob.pop_sent_msg().await;
|
||||
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
|
||||
assert!(!sent.payload.contains("Bob Examplenet"));
|
||||
let mut msg = alice.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
@@ -246,7 +250,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
let chat = alice.get_chat(&bob).await;
|
||||
let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await;
|
||||
assert!(msg.is_info());
|
||||
let expected_text = messages_e2e_encrypted(&alice).await;
|
||||
let expected_text = messages_e2ee_info_msg(&alice).await;
|
||||
assert_eq!(msg.get_text(), expected_text);
|
||||
}
|
||||
|
||||
@@ -261,7 +265,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
|
||||
// Check Alice sent the right message to Bob.
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
assert!(sent.payload.contains(alice_auto_submitted_hdr));
|
||||
assert_eq!(
|
||||
sent.payload.contains("Auto-Submitted: auto-generated"),
|
||||
false
|
||||
);
|
||||
assert!(!sent.payload.contains("Alice Exampleorg"));
|
||||
let msg = bob.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
@@ -288,7 +295,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
// The `SecurejoinWait` info message has been removed, but the e2ee notice remains.
|
||||
let msg = get_chat_msg(&bob, bob_chat.get_id(), 0, 1).await;
|
||||
assert!(msg.is_info());
|
||||
assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await);
|
||||
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -421,18 +428,31 @@ async fn test_setup_contact_concurrent_calls() -> Result<()> {
|
||||
assert!(!alice_id.is_special());
|
||||
assert_eq!(chat.typ, Chattype::Single);
|
||||
assert_ne!(claire_id, alice_id);
|
||||
assert!(
|
||||
assert_eq!(
|
||||
bob.pop_sent_msg()
|
||||
.await
|
||||
.payload()
|
||||
.contains("alice@example.org")
|
||||
.contains("alice@example.org"),
|
||||
false // Alice's address must not be sent in cleartext
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join() -> Result<()> {
|
||||
async fn test_secure_join_group_legacy() -> Result<()> {
|
||||
test_secure_join_group_ex(false, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join_group_v3() -> Result<()> {
|
||||
test_secure_join_group_ex(true, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join_group_v3_without_invite() -> Result<()> {
|
||||
test_secure_join_group_ex(true, true).await
|
||||
}
|
||||
|
||||
async fn test_secure_join_group_ex(v3: bool, remove_invite: bool) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
let bob = tcm.bob().await;
|
||||
@@ -444,7 +464,8 @@ async fn test_secure_join() -> Result<()> {
|
||||
let alice_chatid = chat::create_group(&alice, "the chat").await?;
|
||||
|
||||
tcm.section("Step 1: Generate QR-code, secure-join implied by chatid");
|
||||
let qr = get_securejoin_qr(&alice, Some(alice_chatid)).await.unwrap();
|
||||
let mut qr = get_securejoin_qr(&alice, Some(alice_chatid)).await.unwrap();
|
||||
manipulate_qr(v3, remove_invite, &mut qr);
|
||||
|
||||
tcm.section("Step 2: Bob scans QR-code, sends vg-request");
|
||||
let bob_chatid = join_securejoin(&bob, &qr).await?;
|
||||
@@ -456,9 +477,20 @@ async fn test_secure_join() -> Result<()> {
|
||||
EmailAddress::new("alice@example.org").unwrap()
|
||||
);
|
||||
let msg = alice.parse_msg(&sent).await;
|
||||
assert!(!msg.was_encrypted());
|
||||
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vg-request");
|
||||
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
|
||||
assert!(msg.signature.is_none());
|
||||
assert_eq!(
|
||||
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
||||
if v3 {
|
||||
"vc-request-pubkey"
|
||||
} else {
|
||||
"vg-request"
|
||||
}
|
||||
);
|
||||
assert_eq!(msg.get_header(HeaderDef::SecureJoinAuth).is_some(), v3);
|
||||
assert_eq!(
|
||||
msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some(),
|
||||
!v3
|
||||
);
|
||||
assert!(!msg.header_exists(HeaderDef::AutoSubmitted));
|
||||
|
||||
// Old Delta Chat core sent `Secure-Join-Group` header in `vg-request`,
|
||||
@@ -469,19 +501,18 @@ async fn test_secure_join() -> Result<()> {
|
||||
// is only sent in `vg-request-with-auth` for compatibility.
|
||||
assert!(!msg.header_exists(HeaderDef::SecureJoinGroup));
|
||||
|
||||
tcm.section("Step 3: Alice receives vg-request, sends vg-auth-required");
|
||||
tcm.section("Step 3: Alice receives vc-request-pubkey and sends vc-pubkey, or receives vg-request and sends vg-auth-required");
|
||||
alice.recv_msg_trash(&sent).await;
|
||||
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
|
||||
let msg = bob.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
assert_eq!(
|
||||
msg.get_header(HeaderDef::SecureJoin).unwrap(),
|
||||
"vg-auth-required"
|
||||
if v3 { "vc-pubkey" } else { "vg-auth-required" }
|
||||
);
|
||||
|
||||
tcm.section("Step 4: Bob receives vg-auth-required, sends vg-request-with-auth");
|
||||
tcm.section("Step 4: Bob receives vc-pubkey or vg-auth-required, sends v*-request-with-auth");
|
||||
bob.recv_msg_trash(&sent).await;
|
||||
let sent = bob.pop_sent_msg().await;
|
||||
|
||||
@@ -511,7 +542,6 @@ async fn test_secure_join() -> Result<()> {
|
||||
}
|
||||
|
||||
// Check Bob sent the right handshake message.
|
||||
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
|
||||
let msg = alice.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
assert_eq!(
|
||||
@@ -575,19 +605,27 @@ async fn test_secure_join() -> Result<()> {
|
||||
{
|
||||
// Now Alice's chat with Bob should still be hidden, the verified message should
|
||||
// appear in the group chat.
|
||||
if v3 {
|
||||
assert!(
|
||||
ChatIdBlocked::lookup_by_contact(&alice, contact_bob.id)
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
} else {
|
||||
let chat = alice.get_chat(&bob).await;
|
||||
assert_eq!(
|
||||
chat.blocked,
|
||||
Blocked::Yes,
|
||||
"Alice's 1:1 chat with Bob is not hidden"
|
||||
);
|
||||
}
|
||||
|
||||
let chat = alice.get_chat(&bob).await;
|
||||
assert_eq!(
|
||||
chat.blocked,
|
||||
Blocked::Yes,
|
||||
"Alice's 1:1 chat with Bob is not hidden"
|
||||
);
|
||||
// There should be 2 messages in the chat:
|
||||
// - The ChatProtectionEnabled message
|
||||
// - You added member bob@example.net
|
||||
let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await;
|
||||
assert!(msg.is_info());
|
||||
let expected_text = messages_e2e_encrypted(&alice).await;
|
||||
let expected_text = messages_e2ee_info_msg(&alice).await;
|
||||
assert_eq!(msg.get_text(), expected_text);
|
||||
}
|
||||
|
||||
@@ -640,6 +678,97 @@ async fn test_secure_join() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join_broadcast_legacy() -> Result<()> {
|
||||
test_secure_join_broadcast_ex(false, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join_broadcast_v3() -> Result<()> {
|
||||
test_secure_join_broadcast_ex(true, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_secure_join_broadcast_v3_without_invite() -> Result<()> {
|
||||
test_secure_join_broadcast_ex(true, true).await
|
||||
}
|
||||
|
||||
async fn test_secure_join_broadcast_ex(v3: bool, remove_invite: bool) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat_id = chat::create_broadcast(alice, "Channel".to_string()).await?;
|
||||
let mut qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||
manipulate_qr(v3, remove_invite, &mut qr);
|
||||
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let sent = alice.send_text(alice_chat_id, "Hi channel").await;
|
||||
assert!(sent.recipients.contains("bob@example.net"));
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.chat_id, bob_chat_id);
|
||||
assert_eq!(rcvd.text, "Hi channel");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_setup_contact_compatibility_legacy() -> Result<()> {
|
||||
test_setup_contact_compatibility_ex(false, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_setup_contact_compatibility_v3() -> Result<()> {
|
||||
test_setup_contact_compatibility_ex(true, false).await
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_setup_contact_compatibility_v3_without_invite() -> Result<()> {
|
||||
test_setup_contact_compatibility_ex(true, true).await
|
||||
}
|
||||
|
||||
async fn test_setup_contact_compatibility_ex(v3: bool, remove_invite: bool) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
alice.set_config(Config::Displayname, Some("Alice")).await?;
|
||||
|
||||
let mut qr = get_securejoin_qr(alice, None).await?;
|
||||
manipulate_qr(v3, remove_invite, &mut qr);
|
||||
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
|
||||
assert_eq!(bob_chat.name, "Alice");
|
||||
assert!(bob_chat.can_send(bob).await?);
|
||||
assert_eq!(bob_chat.typ, Chattype::Single);
|
||||
assert_eq!(bob_chat.id, bob.get_chat(alice).await.id);
|
||||
|
||||
let alice_chat = alice.get_chat(bob).await;
|
||||
assert_eq!(alice_chat.name, "bob@example.net");
|
||||
assert!(alice_chat.can_send(alice).await?);
|
||||
assert_eq!(alice_chat.typ, Chattype::Single);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn manipulate_qr(v3: bool, remove_invite: bool, qr: &mut String) {
|
||||
if remove_invite {
|
||||
// Remove the INVITENUBMER. It's not needed in Securejoin v3,
|
||||
// but still included for backwards compatibility reasons.
|
||||
// We want to be able to remove it in the future,
|
||||
// therefore we test that things work without it.
|
||||
let new_qr = Regex::new("&i=.*?&").unwrap().replace(qr, "&");
|
||||
// Broadcast channels use `j` for the INVITENUMBER
|
||||
let new_qr = Regex::new("&j=.*?&").unwrap().replace(&new_qr, "&");
|
||||
assert!(new_qr != *qr);
|
||||
*qr = new_qr.to_string();
|
||||
}
|
||||
// If `!v3`, force legacy securejoin to run by removing the &v=3 parameter.
|
||||
// If `remove_invite`, we can also remove the v=3 parameter,
|
||||
// because a QR with AUTH but no INVITE is obviously v3 QR code.
|
||||
if !v3 || remove_invite {
|
||||
let new_qr = Regex::new("&v=3").unwrap().replace(qr, "");
|
||||
assert!(new_qr != *qr);
|
||||
*qr = new_qr.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_adhoc_group_no_qr() -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
@@ -782,7 +911,18 @@ async fn test_parallel_securejoin() -> Result<()> {
|
||||
/// Tests Bob scanning setup contact QR codes of Alice and Fiona
|
||||
/// concurrently.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_parallel_setup_contact() -> Result<()> {
|
||||
async fn test_parallel_setup_contact_basic() -> Result<()> {
|
||||
test_parallel_setup_contact(false).await
|
||||
}
|
||||
|
||||
/// Tests Bob scanning setup contact QR codes of Alice and Fiona
|
||||
/// concurrently, and then deleting the Fiona contact.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_parallel_setup_contact_bob_deletes_fiona() -> Result<()> {
|
||||
test_parallel_setup_contact(true).await
|
||||
}
|
||||
|
||||
async fn test_parallel_setup_contact(bob_deletes_fiona_contact: bool) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
@@ -803,16 +943,25 @@ async fn test_parallel_setup_contact() -> Result<()> {
|
||||
fiona.recv_msg_trash(&sent_fiona_vc_request).await;
|
||||
let sent_fiona_vc_auth_required = fiona.pop_sent_msg().await;
|
||||
|
||||
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
|
||||
let sent_fiona_vc_request_with_auth = bob.pop_sent_msg().await;
|
||||
|
||||
fiona.recv_msg_trash(&sent_fiona_vc_request_with_auth).await;
|
||||
let sent_fiona_vc_contact_confirm = fiona.pop_sent_msg().await;
|
||||
|
||||
bob.recv_msg_trash(&sent_fiona_vc_contact_confirm).await;
|
||||
let bob_fiona_contact_id = bob.add_or_lookup_contact_id(fiona).await;
|
||||
let bob_fiona_contact = Contact::get_by_id(bob, bob_fiona_contact_id).await.unwrap();
|
||||
assert_eq!(bob_fiona_contact.is_verified(bob).await.unwrap(), true);
|
||||
if bob_deletes_fiona_contact {
|
||||
bob.get_chat(fiona).await.id.delete(bob).await?;
|
||||
Contact::delete(bob, bob_fiona_contact_id).await?;
|
||||
|
||||
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
|
||||
let sent = bob.pop_sent_msg_opt(Duration::ZERO).await;
|
||||
assert!(sent.is_none());
|
||||
} else {
|
||||
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
|
||||
let sent_fiona_vc_request_with_auth = bob.pop_sent_msg().await;
|
||||
|
||||
fiona.recv_msg_trash(&sent_fiona_vc_request_with_auth).await;
|
||||
let sent_fiona_vc_contact_confirm = fiona.pop_sent_msg().await;
|
||||
|
||||
bob.recv_msg_trash(&sent_fiona_vc_contact_confirm).await;
|
||||
let bob_fiona_contact = Contact::get_by_id(bob, bob_fiona_contact_id).await.unwrap();
|
||||
assert_eq!(bob_fiona_contact.is_verified(bob).await.unwrap(), true);
|
||||
}
|
||||
|
||||
// Alice gets online and previously started SecureJoin process finishes.
|
||||
alice.recv_msg_trash(&sent_alice_vc_request).await;
|
||||
@@ -1370,3 +1519,68 @@ gU6dGXsFMe/RpRHrIAkMAaM5xkxMDRuRJDxiUdS/X+Y8
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_auth_token_is_synchronized() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice1 = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
bob.set_config(Config::Displayname, Some("Bob")).await?;
|
||||
|
||||
alice1.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
alice2.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
|
||||
// This creates first auth token:
|
||||
let qr1 = get_securejoin_qr(alice1, None).await?;
|
||||
|
||||
// This creates another auth token; both of them need to be synchronized
|
||||
let qr2 = get_securejoin_qr(alice1, None).await?;
|
||||
sync(alice1, alice2).await;
|
||||
|
||||
// Note that Bob will throw away the AUTH token after sending `vc-request-with-auth`.
|
||||
// Therefore, he will fail to decrypt the answer from Alice's second device,
|
||||
// which leads to a "decryption failed: missing key" message in the logs.
|
||||
// This is fine.
|
||||
tcm.exec_securejoin_qr_multi_device(bob, &[alice1, alice2], &qr2)
|
||||
.await;
|
||||
|
||||
let contacts = Contact::get_all(alice2, 0, Some("Bob")).await?;
|
||||
assert_eq!(contacts[0], alice2.add_or_lookup_contact_id(bob).await);
|
||||
assert_eq!(contacts.len(), 1);
|
||||
|
||||
let chatlist = Chatlist::try_load(alice2, 0, Some("Bob"), None).await?;
|
||||
assert_eq!(chatlist.get_chat_id(0)?, alice2.get_chat(bob).await.id);
|
||||
assert_eq!(chatlist.len(), 1);
|
||||
|
||||
for qr in [qr1, qr2] {
|
||||
let qr = check_qr(bob, &qr).await?;
|
||||
let qr = QrInvite::try_from(qr)?;
|
||||
assert!(token::exists(alice2, Namespace::InviteNumber, qr.invitenumber()).await?);
|
||||
assert!(token::exists(alice2, Namespace::Auth, qr.authcode()).await?);
|
||||
}
|
||||
|
||||
// Check that alice2 only saves the invite number once:
|
||||
let invite_count: u32 = alice2
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT COUNT(*) FROM tokens WHERE namespc=?;",
|
||||
(Namespace::InviteNumber,),
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
assert_eq!(invite_count, 1);
|
||||
|
||||
// ...but knows two AUTH tokens:
|
||||
let auth_count: u32 = alice2
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT COUNT(*) FROM tokens WHERE namespc=?;",
|
||||
(Namespace::Auth,),
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
assert_eq!(auth_count, 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
100
src/sql.rs
100
src/sql.rs
@@ -4,12 +4,11 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use rusqlite::{Connection, OpenFlags, Row, config::DbConfig, types::ValueRef};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::add_device_msg;
|
||||
use crate::config::Config;
|
||||
use crate::constants::DC_CHAT_ID_TRASH;
|
||||
use crate::context::Context;
|
||||
@@ -18,14 +17,12 @@ use crate::ephemeral::start_ephemeral_timers;
|
||||
use crate::imex::BLOBS_BACKUP_NAME;
|
||||
use crate::location::delete_orphaned_poi_locations;
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::message::Message;
|
||||
use crate::message::MsgId;
|
||||
use crate::net::dns::prune_dns_cache;
|
||||
use crate::net::http::http_cache_cleanup;
|
||||
use crate::net::prune_connection_history;
|
||||
use crate::param::{Param, Params};
|
||||
use crate::stock_str;
|
||||
use crate::tools::{SystemTime, Time, delete_file, time, time_elapsed};
|
||||
use crate::tools::{SystemTime, delete_file, time};
|
||||
|
||||
/// Extension to [`rusqlite::ToSql`] trait
|
||||
/// which also includes [`Send`] and [`Sync`].
|
||||
@@ -48,7 +45,7 @@ macro_rules! params_slice {
|
||||
mod migrations;
|
||||
mod pool;
|
||||
|
||||
use pool::Pool;
|
||||
use pool::{Pool, WalCheckpointStats};
|
||||
|
||||
/// A wrapper around the underlying Sqlite3 object.
|
||||
#[derive(Debug)]
|
||||
@@ -302,7 +299,7 @@ impl Sql {
|
||||
/// otherwise allocates write connection.
|
||||
///
|
||||
/// Returns the result of the function.
|
||||
async fn call<'a, F, R>(&'a self, query_only: bool, function: F) -> Result<R>
|
||||
pub async fn call<'a, F, R>(&'a self, query_only: bool, function: F) -> Result<R>
|
||||
where
|
||||
F: 'a + FnOnce(&mut Connection) -> Result<R> + Send,
|
||||
R: Send + 'static,
|
||||
@@ -663,73 +660,30 @@ impl Sql {
|
||||
&self.config_cache
|
||||
}
|
||||
|
||||
/// Runs a checkpoint operation in TRUNCATE mode, so the WAL file is truncated to 0 bytes.
|
||||
pub(crate) async fn wal_checkpoint(context: &Context) -> Result<()> {
|
||||
let t_start = Time::now();
|
||||
let lock = context.sql.pool.read().await;
|
||||
/// Attempts to truncate the WAL file.
|
||||
pub(crate) async fn wal_checkpoint(&self, context: &Context) -> Result<()> {
|
||||
let lock = self.pool.read().await;
|
||||
let Some(pool) = lock.as_ref() else {
|
||||
// No db connections, nothing to checkpoint.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Do as much work as possible without blocking anybody.
|
||||
let query_only = true;
|
||||
let conn = pool.get(query_only).await?;
|
||||
tokio::task::block_in_place(|| {
|
||||
// Execute some transaction causing the WAL file to be opened so that the
|
||||
// `wal_checkpoint()` can proceed, otherwise it fails when called the first time,
|
||||
// see https://sqlite.org/forum/forumpost/7512d76a05268fc8.
|
||||
conn.query_row("PRAGMA table_list", [], |_| Ok(()))?;
|
||||
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(()))
|
||||
})?;
|
||||
|
||||
// Kick out writers.
|
||||
const _: () = assert!(Sql::N_DB_CONNECTIONS > 1, "Deadlock possible");
|
||||
let _write_lock = pool.write_lock().await;
|
||||
let t_writers_blocked = Time::now();
|
||||
// Ensure that all readers use the most recent database snapshot (are at the end of WAL) so
|
||||
// that `wal_checkpoint(FULL)` isn't blocked. We could use `PASSIVE` as well, but it's
|
||||
// documented poorly, https://www.sqlite.org/pragma.html#pragma_wal_checkpoint and
|
||||
// https://www.sqlite.org/c3ref/wal_checkpoint_v2.html don't tell how it interacts with new
|
||||
// readers.
|
||||
let mut read_conns = Vec::with_capacity(Self::N_DB_CONNECTIONS - 1);
|
||||
for _ in 0..(Self::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
read_conns.clear();
|
||||
// Checkpoint the remaining WAL pages without blocking readers.
|
||||
let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| {
|
||||
conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |row| {
|
||||
let pages_total: i64 = row.get(1)?;
|
||||
let pages_checkpointed: i64 = row.get(2)?;
|
||||
Ok((pages_total, pages_checkpointed))
|
||||
})
|
||||
})?;
|
||||
let WalCheckpointStats {
|
||||
total_duration,
|
||||
writers_blocked_duration,
|
||||
readers_blocked_duration,
|
||||
pages_total,
|
||||
pages_checkpointed,
|
||||
} = pool.wal_checkpoint().await?;
|
||||
if pages_checkpointed < pages_total {
|
||||
warn!(
|
||||
context,
|
||||
"Cannot checkpoint whole WAL. Pages total: {pages_total}, checkpointed: {pages_checkpointed}. Make sure there are no external connections running transactions.",
|
||||
);
|
||||
}
|
||||
// Kick out readers to avoid blocking/SQLITE_BUSY.
|
||||
for _ in 0..(Self::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
let t_readers_blocked = Time::now();
|
||||
tokio::task::block_in_place(|| {
|
||||
let blocked = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
|
||||
let blocked: i64 = row.get(0)?;
|
||||
Ok(blocked)
|
||||
})?;
|
||||
ensure!(blocked == 0);
|
||||
Ok(())
|
||||
})?;
|
||||
info!(
|
||||
context,
|
||||
"wal_checkpoint: Total time: {:?}. Writers blocked for: {:?}. Readers blocked for: {:?}.",
|
||||
time_elapsed(&t_start),
|
||||
time_elapsed(&t_writers_blocked),
|
||||
time_elapsed(&t_readers_blocked),
|
||||
"wal_checkpoint: Total time: {total_duration:?}. Writers blocked for: {writers_blocked_duration:?}. Readers blocked for: {readers_blocked_duration:?}."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -828,6 +782,10 @@ async fn incremental_vacuum(context: &Context) -> Result<()> {
|
||||
|
||||
/// Cleanup the account to restore some storage and optimize the database.
|
||||
pub async fn housekeeping(context: &Context) -> Result<()> {
|
||||
let Ok(_housekeeping_lock) = context.housekeeping_mutex.try_lock() else {
|
||||
// Housekeeping is already running in another thread, do nothing.
|
||||
return Ok(());
|
||||
};
|
||||
// Setting `Config::LastHousekeeping` at the beginning avoids endless loops when things do not
|
||||
// work out for whatever reason or are interrupted by the OS.
|
||||
if let Err(e) = context
|
||||
@@ -869,12 +827,6 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
maybe_add_mvbox_move_deprecation_message(context)
|
||||
.await
|
||||
.context("maybe_add_mvbox_move_deprecation_message")
|
||||
.log_err(context)
|
||||
.ok();
|
||||
|
||||
if let Err(err) = incremental_vacuum(context).await {
|
||||
warn!(context, "Failed to run incremental vacuum: {err:#}.");
|
||||
}
|
||||
@@ -882,7 +834,7 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
|
||||
// bigger than 200M) and also make sure we truncate the WAL periodically. Auto-checkponting does
|
||||
// not normally truncate the WAL (unless the `journal_size_limit` pragma is set), see
|
||||
// https://www.sqlite.org/wal.html.
|
||||
if let Err(err) = Sql::wal_checkpoint(context).await {
|
||||
if let Err(err) = Sql::wal_checkpoint(&context.sql, context).await {
|
||||
warn!(context, "wal_checkpoint() failed: {err:#}.");
|
||||
debug_assert!(false);
|
||||
}
|
||||
@@ -934,18 +886,6 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds device message about `mvbox_move` config deprecation
|
||||
/// if the user has it enabled.
|
||||
async fn maybe_add_mvbox_move_deprecation_message(context: &Context) -> Result<()> {
|
||||
if !context.get_config_bool(Config::OnlyFetchMvbox).await?
|
||||
&& context.get_config_bool(Config::MvboxMove).await?
|
||||
{
|
||||
let mut msg = Message::new_text(stock_str::mvbox_move_deprecation(context).await);
|
||||
add_device_msg(context, Some("mvbox_move_deprecation"), Some(&mut msg)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the value of a column `idx` of the `row` as `Vec<u8>`.
|
||||
pub fn row_get_vec(row: &Row, idx: usize) -> rusqlite::Result<Vec<u8>> {
|
||||
row.get(idx).or_else(|err| match row.get_ref(idx)? {
|
||||
|
||||
@@ -2323,6 +2323,70 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT;
|
||||
.await?;
|
||||
}
|
||||
|
||||
inc_and_check(&mut migration_version, 148)?;
|
||||
if dbversion < migration_version {
|
||||
sql.execute_migration_transaction(
|
||||
|transaction| {
|
||||
let only_fetch_mvbox = transaction
|
||||
.query_row(
|
||||
"SELECT value FROM config WHERE keyname='only_fetch_mvbox'",
|
||||
(),
|
||||
|row| {
|
||||
let value: String = row.get(0)?;
|
||||
Ok(value)
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.as_deref()
|
||||
== Some("1");
|
||||
|
||||
if only_fetch_mvbox {
|
||||
let mvbox_folder = transaction
|
||||
.query_row(
|
||||
"SELECT value FROM config WHERE keyname='configured_mvbox_folder'",
|
||||
(),
|
||||
|row| {
|
||||
let value: String = row.get(0)?;
|
||||
Ok(value)
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or_else(|| "DeltaChat".to_string());
|
||||
|
||||
transaction.execute(
|
||||
"UPDATE transports
|
||||
SET entered_param=json_set(entered_param, '$.imap.folder', ?1),
|
||||
configured_param=json_set(configured_param', '$.imap_folder', ?1)",
|
||||
(mvbox_folder,),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
migration_version,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Add UNIQUE bound to token, in order to avoid saving the same token multiple times
|
||||
inc_and_check(&mut migration_version, 149)?;
|
||||
if dbversion < migration_version {
|
||||
sql.execute_migration(
|
||||
"CREATE TABLE tokens_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
namespc INTEGER NOT NULL,
|
||||
foreign_key TEXT DEFAULT '' NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
timestamp INTEGER DEFAULT 0 NOT NULL
|
||||
) STRICT;
|
||||
INSERT OR IGNORE INTO tokens_new
|
||||
SELECT id, namespc, foreign_key, token, timestamp FROM tokens;
|
||||
DROP TABLE tokens;
|
||||
ALTER TABLE tokens_new RENAME TO tokens;",
|
||||
migration_version,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let new_version = sql
|
||||
.get_raw_config_int(VERSION_CFG)
|
||||
.await?
|
||||
|
||||
@@ -51,6 +51,9 @@ use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
|
||||
|
||||
mod wal_checkpoint;
|
||||
pub(crate) use wal_checkpoint::WalCheckpointStats;
|
||||
|
||||
/// Inner connection pool.
|
||||
#[derive(Debug)]
|
||||
struct InnerPool {
|
||||
@@ -68,6 +71,24 @@ struct InnerPool {
|
||||
/// This mutex is locked when write connection
|
||||
/// is outside the pool.
|
||||
pub(crate) write_mutex: Arc<Mutex<()>>,
|
||||
|
||||
/// WAL checkpointing mutex.
|
||||
///
|
||||
/// This mutex ensures that no more than one thread
|
||||
/// runs WAL checkpointing at the same time.
|
||||
///
|
||||
/// Normal procedures acquire either one read connection
|
||||
/// or one write connection with a write mutex,
|
||||
/// and return the resources without trying to acquire
|
||||
/// more connections or trying to acquire write mutex
|
||||
/// without returning the read connection first.
|
||||
/// WAL checkpointing is special, it tries to acquire all
|
||||
/// connections and the write mutex,
|
||||
/// so two threads doing this at the same time
|
||||
/// may result in a deadlock with one thread
|
||||
/// waiting for a write lock and the other thread
|
||||
/// waiting for a connection.
|
||||
wal_checkpoint_mutex: Mutex<()>,
|
||||
}
|
||||
|
||||
impl InnerPool {
|
||||
@@ -188,6 +209,7 @@ impl Pool {
|
||||
connections: parking_lot::Mutex::new(connections),
|
||||
semaphore,
|
||||
write_mutex: Default::default(),
|
||||
wal_checkpoint_mutex: Default::default(),
|
||||
});
|
||||
Pool { inner }
|
||||
}
|
||||
@@ -196,11 +218,8 @@ impl Pool {
|
||||
Arc::clone(&self.inner).get(query_only).await
|
||||
}
|
||||
|
||||
/// Returns a mutex guard guaranteeing that there are no concurrent write connections.
|
||||
///
|
||||
/// NB: Make sure you're not holding all connections when calling this, otherwise it deadlocks
|
||||
/// if there is a concurrent writer waiting for available connection.
|
||||
pub(crate) async fn write_lock(&self) -> OwnedMutexGuard<()> {
|
||||
Arc::clone(&self.inner.write_mutex).lock_owned().await
|
||||
/// Truncates the WAL file.
|
||||
pub(crate) async fn wal_checkpoint(&self) -> Result<WalCheckpointStats> {
|
||||
wal_checkpoint::wal_checkpoint(self).await
|
||||
}
|
||||
}
|
||||
|
||||
93
src/sql/pool/wal_checkpoint.rs
Normal file
93
src/sql/pool/wal_checkpoint.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
//! # WAL checkpointing for SQLite connection pool.
|
||||
|
||||
use anyhow::{Result, ensure};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::sql::Sql;
|
||||
use crate::tools::{Time, time_elapsed};
|
||||
|
||||
use super::Pool;
|
||||
|
||||
/// Information about WAL checkpointing call for logging.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WalCheckpointStats {
|
||||
/// Duration of the whole WAL checkpointing.
|
||||
pub total_duration: Duration,
|
||||
|
||||
/// Duration for which WAL checkpointing blocked the writers.
|
||||
pub writers_blocked_duration: Duration,
|
||||
|
||||
/// Duration for which WAL checkpointing blocked the readers.
|
||||
pub readers_blocked_duration: Duration,
|
||||
|
||||
/// Number of pages in WAL before truncating.
|
||||
pub pages_total: i64,
|
||||
|
||||
/// Number of checkpointed WAL pages.
|
||||
///
|
||||
/// It should be the same as `pages_total`
|
||||
/// unless there are external connections to the database
|
||||
/// that are not in the pool.
|
||||
pub pages_checkpointed: i64,
|
||||
}
|
||||
|
||||
/// Runs a checkpoint operation in TRUNCATE mode, so the WAL file is truncated to 0 bytes.
|
||||
pub(super) async fn wal_checkpoint(pool: &Pool) -> Result<WalCheckpointStats> {
|
||||
let _guard = pool.inner.wal_checkpoint_mutex.lock().await;
|
||||
let t_start = Time::now();
|
||||
|
||||
// Do as much work as possible without blocking anybody.
|
||||
let query_only = true;
|
||||
let conn = pool.get(query_only).await?;
|
||||
tokio::task::block_in_place(|| {
|
||||
// Execute some transaction causing the WAL file to be opened so that the
|
||||
// `wal_checkpoint()` can proceed, otherwise it fails when called the first time,
|
||||
// see https://sqlite.org/forum/forumpost/7512d76a05268fc8.
|
||||
conn.query_row("PRAGMA table_list", [], |_| Ok(()))?;
|
||||
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(()))
|
||||
})?;
|
||||
|
||||
// Kick out writers.
|
||||
const _: () = assert!(Sql::N_DB_CONNECTIONS > 1, "Deadlock possible");
|
||||
let _write_lock = Arc::clone(&pool.inner.write_mutex).lock_owned().await;
|
||||
let t_writers_blocked = Time::now();
|
||||
// Ensure that all readers use the most recent database snapshot (are at the end of WAL) so
|
||||
// that `wal_checkpoint(FULL)` isn't blocked. We could use `PASSIVE` as well, but it's
|
||||
// documented poorly, https://www.sqlite.org/pragma.html#pragma_wal_checkpoint and
|
||||
// https://www.sqlite.org/c3ref/wal_checkpoint_v2.html don't tell how it interacts with new
|
||||
// readers.
|
||||
let mut read_conns = Vec::with_capacity(crate::sql::Sql::N_DB_CONNECTIONS - 1);
|
||||
for _ in 0..(crate::sql::Sql::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
read_conns.clear();
|
||||
// Checkpoint the remaining WAL pages without blocking readers.
|
||||
let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| {
|
||||
conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |row| {
|
||||
let pages_total: i64 = row.get(1)?;
|
||||
let pages_checkpointed: i64 = row.get(2)?;
|
||||
Ok((pages_total, pages_checkpointed))
|
||||
})
|
||||
})?;
|
||||
// Kick out readers to avoid blocking/SQLITE_BUSY.
|
||||
for _ in 0..(crate::sql::Sql::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
let t_readers_blocked = Time::now();
|
||||
tokio::task::block_in_place(|| {
|
||||
let blocked = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
|
||||
let blocked: i64 = row.get(0)?;
|
||||
Ok(blocked)
|
||||
})?;
|
||||
ensure!(blocked == 0);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(WalCheckpointStats {
|
||||
total_duration: time_elapsed(&t_start),
|
||||
writers_blocked_duration: time_elapsed(&t_writers_blocked),
|
||||
readers_blocked_duration: time_elapsed(&t_readers_blocked),
|
||||
pages_total,
|
||||
pages_checkpointed,
|
||||
})
|
||||
}
|
||||
@@ -391,6 +391,12 @@ https://delta.chat/donate"))]
|
||||
Waiting for the device of %2$s to reply…"))]
|
||||
SecureJoinBroadcastStarted = 203,
|
||||
|
||||
#[strum(props(fallback = "Channel name changed from \"%1$s\" to \"%2$s\"."))]
|
||||
MsgBroadcastNameChanged = 204,
|
||||
|
||||
#[strum(props(fallback = "Channel image changed."))]
|
||||
MsgBroadcastImgChanged = 205,
|
||||
|
||||
#[strum(props(
|
||||
fallback = "The attachment contains anonymous usage statistics, which helps us improve Delta Chat. Thank you!"
|
||||
))]
|
||||
@@ -407,11 +413,6 @@ https://delta.chat/donate"))]
|
||||
#[strum(props(fallback = "Messages in this chat use classic email and are not encrypted."))]
|
||||
ChatUnencryptedExplanation = 230,
|
||||
|
||||
#[strum(props(
|
||||
fallback = "You are using the legacy option \"Settings → Advanced → Move automatically to DeltaChat Folder\".\n\nThis option will be removed in a few weeks and you should disable it already today.\n\nIf having chat messages mixed into your inbox is a problem, see https://delta.chat/legacy-move"
|
||||
))]
|
||||
MvboxMoveDeprecation = 231,
|
||||
|
||||
#[strum(props(fallback = "Outgoing audio call"))]
|
||||
OutgoingAudioCall = 232,
|
||||
|
||||
@@ -429,6 +430,9 @@ https://delta.chat/donate"))]
|
||||
|
||||
#[strum(props(fallback = "Chat description changed by %1$s."))]
|
||||
MsgChatDescriptionChangedBy = 241,
|
||||
|
||||
#[strum(props(fallback = "Messages are end-to-end encrypted."))]
|
||||
MessagesAreE2ee = 242,
|
||||
}
|
||||
|
||||
impl StockMessage {
|
||||
@@ -620,10 +624,10 @@ pub(crate) async fn msg_chat_description_changed(
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `You added member %1$s.` or `Member %1$s added by %2$s.`.
|
||||
/// Stock string: `Member %1$s added.`, `You added member %1$s.` or `Member %1$s added by %2$s.`.
|
||||
///
|
||||
/// The `added_member_addr` parameter should be an email address and is looked up in the
|
||||
/// contacts to combine with the display name.
|
||||
/// The `added_member` and `by_contact` contacts
|
||||
/// are looked up in the database to get the display names.
|
||||
pub(crate) async fn msg_add_member_local(
|
||||
context: &Context,
|
||||
added_member: ContactId,
|
||||
@@ -646,10 +650,10 @@ pub(crate) async fn msg_add_member_local(
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `I added member %1$s.` or `Member %1$s removed by %2$s.`.
|
||||
/// Stock string: `Member %1$s removed.` or `You removed member %1$s.` or `Member %1$s removed by %2$s.`
|
||||
///
|
||||
/// The `removed_member_addr` parameter should be an email address and is looked up in
|
||||
/// the contacts to combine with the display name.
|
||||
/// The `removed_member` and `by_contact` contacts
|
||||
/// are looked up in the database to get the display names.
|
||||
pub(crate) async fn msg_del_member_local(
|
||||
context: &Context,
|
||||
removed_member: ContactId,
|
||||
@@ -708,6 +712,19 @@ pub(crate) async fn secure_join_broadcast_started(
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `Channel name changed from "1%s" to "2$s".`
|
||||
pub(crate) async fn msg_broadcast_name_changed(context: &Context, from: &str, to: &str) -> String {
|
||||
translated(context, StockMessage::MsgBroadcastNameChanged)
|
||||
.await
|
||||
.replace1(from)
|
||||
.replace2(to)
|
||||
}
|
||||
|
||||
/// Stock string `Channel image changed.`
|
||||
pub(crate) async fn msg_broadcast_img_changed(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgBroadcastImgChanged).await
|
||||
}
|
||||
|
||||
/// Stock string: `You reacted %1$s to "%2$s"` or `%1$s reacted %2$s to "%3$s"`.
|
||||
pub(crate) async fn msg_reacted(
|
||||
context: &Context,
|
||||
@@ -1049,11 +1066,16 @@ pub(crate) async fn error_no_network(context: &Context) -> String {
|
||||
translated(context, StockMessage::ErrorNoNetwork).await
|
||||
}
|
||||
|
||||
/// Stock string: `Messages are end-to-end encrypted.`
|
||||
pub(crate) async fn messages_e2e_encrypted(context: &Context) -> String {
|
||||
/// Stock string: `Messages are end-to-end encrypted.`, used in info-messages, UI may add smth. as `Tap to learn more.`
|
||||
pub(crate) async fn messages_e2ee_info_msg(context: &Context) -> String {
|
||||
translated(context, StockMessage::ChatProtectionEnabled).await
|
||||
}
|
||||
|
||||
/// Stock string: `Messages are end-to-end encrypted.`
|
||||
pub(crate) async fn messages_are_e2ee(context: &Context) -> String {
|
||||
translated(context, StockMessage::MessagesAreE2ee).await
|
||||
}
|
||||
|
||||
/// Stock string: `Reply`.
|
||||
pub(crate) async fn reply_noun(context: &Context) -> String {
|
||||
translated(context, StockMessage::ReplyNoun).await
|
||||
@@ -1269,11 +1291,6 @@ pub(crate) async fn chat_unencrypted_explanation(context: &Context) -> String {
|
||||
translated(context, StockMessage::ChatUnencryptedExplanation).await
|
||||
}
|
||||
|
||||
/// Stock string: `You are using the legacy option "Move automatically to DeltaChat Folder`…
|
||||
pub(crate) async fn mvbox_move_deprecation(context: &Context) -> String {
|
||||
translated(context, StockMessage::MvboxMoveDeprecation).await
|
||||
}
|
||||
|
||||
impl Viewtype {
|
||||
/// returns Localized name for message viewtype
|
||||
pub async fn to_locale_string(&self, context: &Context) -> String {
|
||||
|
||||
14
src/sync.rs
14
src/sync.rs
@@ -789,19 +789,7 @@ mod tests {
|
||||
|
||||
let bob = &tcm.bob().await;
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
let msg_id = alice.send_sync_msg().await?;
|
||||
// Core <= v1.143 doesn't sync QR code tokens immediately, so current Core does that when a
|
||||
// group is promoted for compatibility (because the group could be created by older Core).
|
||||
// TODO: assert!(msg_id.is_none());
|
||||
assert!(msg_id.is_some());
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
let msg = alice.parse_msg(&sent).await;
|
||||
let mut sync_items = msg.sync_items.unwrap().items;
|
||||
assert_eq!(sync_items.len(), 1);
|
||||
let data = sync_items.pop().unwrap().data;
|
||||
let SyncDataOrUnknown::SyncData(AddQrToken(_)) = data else {
|
||||
unreachable!();
|
||||
};
|
||||
assert!(alice.send_sync_msg().await?.is_none());
|
||||
|
||||
// Remove Bob because alice2 doesn't have their key.
|
||||
let alice_bob_id = alice.add_or_lookup_contact(bob).await.id;
|
||||
|
||||
@@ -15,6 +15,7 @@ use async_channel::{self as channel, Receiver, Sender};
|
||||
use chat::ChatItem;
|
||||
use deltachat_contact_tools::{ContactAddress, EmailAddress};
|
||||
use nu_ansi_term::Color;
|
||||
use pgp::composed::SignedSecretKey;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
use tokio::runtime::Handle;
|
||||
@@ -37,7 +38,6 @@ use crate::log::warn;
|
||||
use crate::login_param::EnteredLoginParam;
|
||||
use crate::message::{Message, MessageState, MsgId, update_msg_state};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::pgp::KeyPair;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::securejoin::{get_securejoin_qr, join_securejoin};
|
||||
use crate::stock_str::StockStrings;
|
||||
@@ -301,7 +301,7 @@ impl TestContextManager {
|
||||
/// Builder for the [TestContext].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TestContextBuilder {
|
||||
key_pair: Option<KeyPair>,
|
||||
key_pair: Option<SignedSecretKey>,
|
||||
|
||||
/// Log sink if set.
|
||||
///
|
||||
@@ -364,11 +364,11 @@ impl TestContextBuilder {
|
||||
self.with_key_pair(fiona_keypair())
|
||||
}
|
||||
|
||||
/// Configures the new [`TestContext`] with the provided [`KeyPair`].
|
||||
/// Configures the new [`TestContext`] with the provided [`SignedSecretKey`].
|
||||
///
|
||||
/// This will extract the email address from the key and configure the context with the
|
||||
/// given identity.
|
||||
pub fn with_key_pair(mut self, key_pair: KeyPair) -> Self {
|
||||
pub fn with_key_pair(mut self, key_pair: SignedSecretKey) -> Self {
|
||||
self.key_pair = Some(key_pair);
|
||||
self
|
||||
}
|
||||
@@ -396,7 +396,7 @@ impl TestContextBuilder {
|
||||
pub async fn build(self, used_names: Option<&mut BTreeSet<String>>) -> TestContext {
|
||||
if let Some(key_pair) = self.key_pair {
|
||||
let userid = {
|
||||
let public_key = &key_pair.public;
|
||||
let public_key = key_pair.to_public_key();
|
||||
let id_bstr = public_key.details.users.first().unwrap().id.id();
|
||||
String::from_utf8(id_bstr.to_vec()).unwrap()
|
||||
};
|
||||
@@ -567,7 +567,6 @@ impl TestContext {
|
||||
.unwrap();
|
||||
ctx.set_config(Config::BccSelf, Some("1")).await.unwrap();
|
||||
ctx.set_config(Config::SyncMsgs, Some("0")).await.unwrap();
|
||||
ctx.set_config(Config::MvboxMove, Some("0")).await.unwrap();
|
||||
|
||||
Self {
|
||||
ctx,
|
||||
@@ -1352,62 +1351,43 @@ impl SentMessage<'_> {
|
||||
/// This saves CPU cycles by avoiding having to generate a key.
|
||||
///
|
||||
/// The keypair was created using the crate::key::tests::gen_key test.
|
||||
pub fn alice_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/alice-secret.asc")).unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
pub fn alice_keypair() -> SignedSecretKey {
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/alice-secret.asc")).unwrap()
|
||||
}
|
||||
|
||||
/// Load a pre-generated keypair for bob@example.net from disk.
|
||||
///
|
||||
/// Like [alice_keypair] but a different key and identity.
|
||||
pub fn bob_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
pub fn bob_keypair() -> SignedSecretKey {
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap()
|
||||
}
|
||||
|
||||
/// Load a pre-generated keypair for charlie@example.net from disk.
|
||||
///
|
||||
/// Like [alice_keypair] but a different key and identity.
|
||||
pub fn charlie_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/charlie-secret.asc"))
|
||||
.unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
pub fn charlie_keypair() -> SignedSecretKey {
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/charlie-secret.asc")).unwrap()
|
||||
}
|
||||
|
||||
/// Load a pre-generated keypair for dom@example.net from disk.
|
||||
///
|
||||
/// Like [alice_keypair] but a different key and identity.
|
||||
pub fn dom_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/dom-secret.asc")).unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
pub fn dom_keypair() -> SignedSecretKey {
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/dom-secret.asc")).unwrap()
|
||||
}
|
||||
|
||||
/// Load a pre-generated keypair for elena@example.net from disk.
|
||||
///
|
||||
/// Like [alice_keypair] but a different key and identity.
|
||||
pub fn elena_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/elena-secret.asc")).unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
pub fn elena_keypair() -> SignedSecretKey {
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/elena-secret.asc")).unwrap()
|
||||
}
|
||||
|
||||
/// Load a pre-generated keypair for fiona@example.net from disk.
|
||||
///
|
||||
/// Like [alice_keypair] but a different key and identity.
|
||||
pub fn fiona_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/fiona-secret.asc")).unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
pub fn fiona_keypair() -> SignedSecretKey {
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/fiona-secret.asc")).unwrap()
|
||||
}
|
||||
|
||||
/// Utility to help wait for and retrieve events.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Tests about receiving Pre-Messages and Post-Message
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use crate::EventType;
|
||||
@@ -175,6 +175,44 @@ async fn test_receive_pre_message_and_dl_post_message() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test receiving the Post-Message after receiving the pre-message twice.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_receive_pre_message_twice() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice_group_id = alice.create_group_with_members("test group", &[bob]).await;
|
||||
|
||||
let (pre_message, post_message, _alice_msg_id) =
|
||||
send_large_file_message(alice, alice_group_id, Viewtype::File, &vec![0u8; 1_000_000])
|
||||
.await?;
|
||||
|
||||
let msg = bob.recv_msg(&pre_message).await;
|
||||
assert!(bob.recv_msg_opt(&pre_message).await.is_none());
|
||||
|
||||
// Pre-message should still be there.
|
||||
// Due to a bug receiving pre-message second time
|
||||
// deleted it in 2.44.0.
|
||||
// This is a regression test.
|
||||
let msg = Message::load_from_db(bob, msg.id)
|
||||
.await
|
||||
.context("Pre-message should still exist after receiving it twice")?;
|
||||
assert_eq!(msg.download_state(), DownloadState::Available);
|
||||
assert_eq!(msg.viewtype, Viewtype::Text);
|
||||
assert!(msg.param.exists(Param::PostMessageViewtype));
|
||||
assert!(msg.param.exists(Param::PostMessageFileBytes));
|
||||
assert_eq!(msg.text, "test".to_owned());
|
||||
|
||||
let _ = bob.recv_msg_trash(&post_message).await;
|
||||
let msg = Message::load_from_db(bob, msg.id).await?;
|
||||
assert_eq!(msg.download_state(), DownloadState::Done);
|
||||
assert_eq!(msg.viewtype, Viewtype::File);
|
||||
assert_eq!(msg.param.exists(Param::PostMessageViewtype), false);
|
||||
assert_eq!(msg.param.exists(Param::PostMessageFileBytes), false);
|
||||
assert_eq!(msg.text, "test".to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test out of order receiving. Post-Message is received & downloaded before pre-message.
|
||||
/// In that case pre-message shall be trashed.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -195,7 +195,7 @@ async fn test_degrade_verified_oneonone_chat() -> Result<()> {
|
||||
.await?;
|
||||
|
||||
let msg0 = get_chat_msg(&alice, alice_chat.id, 0, 1).await;
|
||||
let enabled = stock_str::messages_e2e_encrypted(&alice).await;
|
||||
let enabled = stock_str::messages_e2ee_info_msg(&alice).await;
|
||||
assert_eq!(msg0.text, enabled);
|
||||
assert_eq!(msg0.param.get_cmd(), SystemMessage::ChatE2ee);
|
||||
|
||||
|
||||
@@ -30,10 +30,14 @@ pub async fn save(
|
||||
token: &str,
|
||||
timestamp: i64,
|
||||
) -> Result<()> {
|
||||
if token.is_empty() {
|
||||
info!(context, "Not saving empty {namespace} token");
|
||||
return Ok(());
|
||||
}
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO tokens (namespc, foreign_key, token, timestamp) VALUES (?, ?, ?, ?)",
|
||||
"INSERT OR IGNORE INTO tokens (namespc, foreign_key, token, timestamp) VALUES (?, ?, ?, ?)",
|
||||
(namespace, foreign_key.unwrap_or(""), token, timestamp),
|
||||
)
|
||||
.await?;
|
||||
@@ -56,7 +60,7 @@ pub async fn lookup(
|
||||
context
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT token FROM tokens WHERE namespc=? AND foreign_key=? ORDER BY timestamp DESC LIMIT 1",
|
||||
"SELECT token FROM tokens WHERE namespc=? AND foreign_key=? ORDER BY id DESC LIMIT 1",
|
||||
(namespace, foreign_key.unwrap_or("")),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::config::Config;
|
||||
use crate::configure::server_params::{ServerParams, expand_param_vector};
|
||||
use crate::constants::{DC_LP_AUTH_FLAGS, DC_LP_AUTH_OAUTH2};
|
||||
use crate::context::Context;
|
||||
use crate::ensure_and_debug_assert;
|
||||
use crate::events::EventType;
|
||||
use crate::login_param::EnteredLoginParam;
|
||||
use crate::net::load_connection_timestamp;
|
||||
@@ -163,22 +164,30 @@ pub(crate) struct ConfiguredLoginParam {
|
||||
/// `From:` address that was used at the time of configuration.
|
||||
pub addr: String,
|
||||
|
||||
/// List of IMAP candidates to try.
|
||||
pub imap: Vec<ConfiguredServerLoginParam>,
|
||||
|
||||
// Custom IMAP user.
|
||||
//
|
||||
// This overwrites autoconfig from the provider database
|
||||
// if non-empty.
|
||||
/// Custom IMAP user.
|
||||
///
|
||||
/// This overwrites autoconfig from the provider database
|
||||
/// if non-empty.
|
||||
pub imap_user: String,
|
||||
|
||||
pub imap_password: String,
|
||||
|
||||
// IMAP folder to watch.
|
||||
//
|
||||
// If not stored, should be interpreted as "INBOX".
|
||||
// If stored, should be a folder name and not empty.
|
||||
pub imap_folder: Option<String>,
|
||||
|
||||
/// List of SMTP candidates to try.
|
||||
pub smtp: Vec<ConfiguredServerLoginParam>,
|
||||
|
||||
// Custom SMTP user.
|
||||
//
|
||||
// This overwrites autoconfig from the provider database
|
||||
// if non-empty.
|
||||
/// Custom SMTP user.
|
||||
///
|
||||
/// This overwrites autoconfig from the provider database
|
||||
/// if non-empty.
|
||||
pub smtp_user: String,
|
||||
|
||||
pub smtp_password: String,
|
||||
@@ -199,6 +208,13 @@ pub(crate) struct ConfiguredLoginParam {
|
||||
pub(crate) struct ConfiguredLoginParamJson {
|
||||
pub addr: String,
|
||||
pub imap: Vec<ConfiguredServerLoginParam>,
|
||||
|
||||
/// IMAP folder to watch.
|
||||
///
|
||||
/// Defaults to "INBOX" if unset.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub imap_folder: Option<String>,
|
||||
|
||||
pub imap_user: String,
|
||||
pub imap_password: String,
|
||||
pub smtp: Vec<ConfiguredServerLoginParam>,
|
||||
@@ -545,6 +561,7 @@ impl ConfiguredLoginParam {
|
||||
Ok(Some(ConfiguredLoginParam {
|
||||
addr,
|
||||
imap,
|
||||
imap_folder: None,
|
||||
imap_user: mail_user,
|
||||
imap_password: mail_pw,
|
||||
smtp,
|
||||
@@ -569,11 +586,18 @@ impl ConfiguredLoginParam {
|
||||
pub(crate) fn from_json(json: &str) -> Result<Self> {
|
||||
let json: ConfiguredLoginParamJson = serde_json::from_str(json)?;
|
||||
|
||||
ensure_and_debug_assert!(
|
||||
json.imap_folder
|
||||
.as_ref()
|
||||
.is_none_or(|folder| !folder.is_empty()),
|
||||
"Configured watched folder name cannot be empty"
|
||||
);
|
||||
let provider = json.provider_id.and_then(|id| get_provider_by_id(&id));
|
||||
|
||||
Ok(ConfiguredLoginParam {
|
||||
addr: json.addr,
|
||||
imap: json.imap,
|
||||
imap_folder: json.imap_folder,
|
||||
imap_user: json.imap_user,
|
||||
imap_password: json.imap_password,
|
||||
smtp: json.smtp,
|
||||
@@ -611,6 +635,7 @@ impl From<ConfiguredLoginParam> for ConfiguredLoginParamJson {
|
||||
imap: configured_login_param.imap,
|
||||
imap_user: configured_login_param.imap_user,
|
||||
imap_password: configured_login_param.imap_password,
|
||||
imap_folder: configured_login_param.imap_folder,
|
||||
smtp: configured_login_param.smtp,
|
||||
smtp_user: configured_login_param.smtp_user,
|
||||
smtp_password: configured_login_param.smtp_password,
|
||||
@@ -629,9 +654,16 @@ pub(crate) async fn save_transport(
|
||||
configured: &ConfiguredLoginParamJson,
|
||||
add_timestamp: i64,
|
||||
) -> Result<bool> {
|
||||
ensure_and_debug_assert!(
|
||||
configured
|
||||
.imap_folder
|
||||
.as_ref()
|
||||
.is_none_or(|folder| !folder.is_empty()),
|
||||
"Configured watched folder name cannot be empty"
|
||||
);
|
||||
|
||||
let addr = addr_normalize(&configured.addr);
|
||||
let configured_addr = context.get_config(Config::ConfiguredAddr).await?;
|
||||
|
||||
let mut modified = context
|
||||
.sql
|
||||
.execute(
|
||||
@@ -669,6 +701,9 @@ pub(crate) async fn save_transport(
|
||||
pub(crate) async fn send_sync_transports(context: &Context) -> Result<()> {
|
||||
info!(context, "Sending transport synchronization message.");
|
||||
|
||||
// Regenerate public key to include all transports.
|
||||
context.self_public_key.lock().await.take();
|
||||
|
||||
// Synchronize all transport configurations.
|
||||
//
|
||||
// Transport with ID 1 is never synchronized
|
||||
@@ -761,6 +796,7 @@ pub(crate) async fn sync_transports(
|
||||
.await?;
|
||||
|
||||
if modified {
|
||||
context.self_public_key.lock().await.take();
|
||||
tokio::task::spawn(restart_io_if_running_boxed(context.clone()));
|
||||
context.emit_event(EventType::TransportsModified);
|
||||
}
|
||||
@@ -820,6 +856,7 @@ mod tests {
|
||||
},
|
||||
user: "alice".to_string(),
|
||||
}],
|
||||
imap_folder: None,
|
||||
imap_user: "".to_string(),
|
||||
imap_password: "foo".to_string(),
|
||||
smtp: vec![ConfiguredServerLoginParam {
|
||||
@@ -928,6 +965,7 @@ mod tests {
|
||||
user: user.to_string(),
|
||||
},
|
||||
],
|
||||
imap_folder: None,
|
||||
imap_user: "alice@posteo.de".to_string(),
|
||||
imap_password: "foobarbaz".to_string(),
|
||||
smtp: vec![
|
||||
@@ -1041,6 +1079,7 @@ mod tests {
|
||||
},
|
||||
user: addr.clone(),
|
||||
}],
|
||||
imap_folder: None,
|
||||
imap_user: addr.clone(),
|
||||
imap_password: "foobarbaz".to_string(),
|
||||
smtp: vec![ConfiguredServerLoginParam {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
OutBroadcast#Chat#1001: My Channel [1 member(s)] Icon: e9b6c7a78aa2e4f415644f55a553e73.png
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#1002🔒: Me (Contact#Contact#Self): You changed the group image. [INFO] √
|
||||
Msg#1006🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#1002🔒: Me (Contact#Contact#Self): Channel image changed. [INFO] √
|
||||
Msg#1005🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Single#Chat#1002: bob@example.net [KEY bob@example.net]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#1003: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#1007: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
OutBroadcast#Chat#1001: Channel [0 member(s)]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#1007🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#1009🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#1010🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
Msg#1006🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#1008🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#1009🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
OutBroadcast#Chat#1001: Channel [0 member(s)]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#1002: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#1007🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#1009🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#1010🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
Msg#1006🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#1008🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#1009🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user