Compare commits
No commits in common. "189377a3a392e3e6ef051ae1f6b2d1cdd0e7187f" and "1220f89fc6f8ef427df505f72a156e8f6fbfd925" have entirely different histories.
189377a3a3
...
1220f89fc6
5 changed files with 25 additions and 385 deletions
|
|
@ -410,12 +410,11 @@ jobs:
|
||||||
# A floor, not a ratchet: these tests are cheap, pure, and the
|
# A floor, not a ratchet: these tests are cheap, pure, and the
|
||||||
# number should only go up. 38 at Phase 0; 154 after C1c/C1d;
|
# number should only go up. 38 at Phase 0; 154 after C1c/C1d;
|
||||||
# 195 after the Phase C/D completion; 214 after Phase E (proptest +
|
# 195 after the Phase C/D completion; 214 after Phase E (proptest +
|
||||||
# the SMTP sink integration tests); 216 after the §8 TLS handshake
|
# the SMTP sink integration tests).
|
||||||
# tests.
|
|
||||||
- name: The email domain test suite must not shrink
|
- name: The email domain test suite must not shrink
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
FLOOR=210
|
FLOOR=205
|
||||||
out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: 2>&1)"
|
out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: 2>&1)"
|
||||||
|
|
||||||
# C1e/C1f: the IMAP transport and the platform keystore are both
|
# C1e/C1f: the IMAP transport and the platform keystore are both
|
||||||
|
|
@ -426,20 +425,6 @@ jobs:
|
||||||
- name: The IMAP and keystore features must compile
|
- name: The IMAP and keystore features must compile
|
||||||
run: cargo check --locked -p nigig-core --features imap,keystore
|
run: cargo check --locked -p nigig-core --features imap,keystore
|
||||||
|
|
||||||
# §8 of the assessment: "the wasm path has not been built." The
|
|
||||||
# credential-bearing proxy POST (call_email_api, WasmFetchTransport,
|
|
||||||
# set_email_api_url) is #[cfg(target_arch = "wasm32")], so a host-only
|
|
||||||
# build never type-checks it — which is how a browser-only breakage
|
|
||||||
# would reach main. This installs the wasm target and checks the
|
|
||||||
# email domain's wasm half. (no-default-features: the `native` feature
|
|
||||||
# would pull tokio/reqwest/lettre, which do not exist for wasm.)
|
|
||||||
- name: The wasm path must compile
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
rustup target add wasm32-unknown-unknown
|
|
||||||
cargo check --locked --target wasm32-unknown-unknown -p nigig-core \
|
|
||||||
--no-default-features --features async-rt
|
|
||||||
|
|
||||||
# A coverage number that is only printed drifts down. This enforces a
|
# A coverage number that is only printed drifts down. This enforces a
|
||||||
# whole-domain floor plus per-file floors on the files that have
|
# whole-domain floor plus per-file floors on the files that have
|
||||||
# actually harboured bugs (the SMTP password serialisation, the bulk
|
# actually harboured bugs (the SMTP password serialisation, the bulk
|
||||||
|
|
|
||||||
93
Cargo.lock
generated
93
Cargo.lock
generated
|
|
@ -395,29 +395,6 @@ version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aws-lc-rs"
|
|
||||||
version = "1.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
|
||||||
dependencies = [
|
|
||||||
"aws-lc-sys",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aws-lc-sys"
|
|
||||||
version = "0.44.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"cmake",
|
|
||||||
"dunce",
|
|
||||||
"fs_extra",
|
|
||||||
"pkg-config 0.3.34",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
|
|
@ -617,8 +594,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
|
||||||
"libc",
|
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -725,15 +700,6 @@ version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cmake"
|
|
||||||
version = "0.1.58"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cobs"
|
name = "cobs"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|
@ -973,12 +939,6 @@ name = "downcast-rs"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc"
|
source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dunce"
|
|
||||||
version = "1.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
|
|
@ -1201,12 +1161,6 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fs_extra"
|
|
||||||
version = "1.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
|
|
@ -1947,16 +1901,6 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jobserver"
|
|
||||||
version = "0.1.35"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.4.3",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.104"
|
version = "0.3.104"
|
||||||
|
|
@ -3042,17 +2986,14 @@ dependencies = [
|
||||||
"postcard",
|
"postcard",
|
||||||
"proptest",
|
"proptest",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"rcgen",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"robius-directories",
|
"robius-directories",
|
||||||
"robius-location",
|
"robius-location",
|
||||||
"robius-open",
|
"robius-open",
|
||||||
"robius-ussd",
|
"robius-ussd",
|
||||||
"rustls",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
|
@ -3803,16 +3744,6 @@ dependencies = [
|
||||||
"windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
"windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pem"
|
|
||||||
version = "3.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.22.1",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
|
|
@ -4215,19 +4146,6 @@ dependencies = [
|
||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rcgen"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
|
||||||
dependencies = [
|
|
||||||
"pem",
|
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"time",
|
|
||||||
"yasna",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
|
|
@ -4561,7 +4479,6 @@ version = "0.23.43"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
|
||||||
"log 0.4.33",
|
"log 0.4.33",
|
||||||
"once_cell 1.21.4",
|
"once_cell 1.21.4",
|
||||||
"ring",
|
"ring",
|
||||||
|
|
@ -4587,7 +4504,6 @@ version = "0.103.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
|
|
@ -6112,15 +6028,6 @@ version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "yasna"
|
|
||||||
version = "0.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
|
||||||
dependencies = [
|
|
||||||
"time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|
|
||||||
|
|
@ -548,7 +548,6 @@ snapshot. Commits are on `main`.
|
||||||
| *(this turn)* | **Phase C + D COMPLETE** — C1e (`imap_client.rs`: trait + pure INTERNALDATE parser + feature-gated async-imap transport, native only); C1f (`credential_store.rs`: trait + fail-closed default); C3 (`email_cache.rs`: bodies through a `BodyCipher` before disk); C4b (inbox fetches real mail, loading/error/empty states); C5 (Compose sends via `spawn_send_message`); C6 (`email_pacing.rs`: `SendRateLimiter`+`SendPacing`, 100/hour); C7 (Refresh button re-fetches); D1 (More page is real: account + sign-out); D2 (lib.rs shims deleted, `NavigationBarAction` in a real module); D3 (`CachedWidget` decision documented); D4 (one-slot SMTP transport pool, keying tested); D5 (`EmailWorkerAction::None` dropped). Domain tests **154 → 195**. Coverage now **90.7%** over 12 files, floors enforced. |
|
| *(this turn)* | **Phase C + D COMPLETE** — C1e (`imap_client.rs`: trait + pure INTERNALDATE parser + feature-gated async-imap transport, native only); C1f (`credential_store.rs`: trait + fail-closed default); C3 (`email_cache.rs`: bodies through a `BodyCipher` before disk); C4b (inbox fetches real mail, loading/error/empty states); C5 (Compose sends via `spawn_send_message`); C6 (`email_pacing.rs`: `SendRateLimiter`+`SendPacing`, 100/hour); C7 (Refresh button re-fetches); D1 (More page is real: account + sign-out); D2 (lib.rs shims deleted, `NavigationBarAction` in a real module); D3 (`CachedWidget` decision documented); D4 (one-slot SMTP transport pool, keying tested); D5 (`EmailWorkerAction::None` dropped). Domain tests **154 → 195**. Coverage now **90.7%** over 12 files, floors enforced. |
|
||||||
| *(this turn)* | **C6/C7/C1f gaps closed** — C6: `email_bulk.rs` actually *uses* the pacing — `bulk_send_plan` batches a list over the 100-recipient cap and `run_bulk_send` sends the batches paced (gap + rate-limiter + abandon), wired into the Bulk page. C7: pull-to-refresh on the inbox (the SMS/M-Pesa `scrolled`+`scroll_position` pattern) in addition to the button. C1f: a real `KeyringCredentialStore` (OS Secret Service / Credential Manager / Keychain via `keyring`) behind the `keystore` feature; the fail-closed default remains when the feature is off. Domain tests **195 → 206**. |
|
| *(this turn)* | **C6/C7/C1f gaps closed** — C6: `email_bulk.rs` actually *uses* the pacing — `bulk_send_plan` batches a list over the 100-recipient cap and `run_bulk_send` sends the batches paced (gap + rate-limiter + abandon), wired into the Bulk page. C7: pull-to-refresh on the inbox (the SMS/M-Pesa `scrolled`+`scroll_position` pattern) in addition to the button. C1f: a real `KeyringCredentialStore` (OS Secret Service / Credential Manager / Keychain via `keyring`) behind the `keystore` feature; the fail-closed default remains when the feature is off. Domain tests **195 → 206**. |
|
||||||
| *(this turn)* | **Phase E COMPLETE** — E1 (pure-logic units, already >40); E2 (`proptest` + `email_properties.rs`: never-panic + structural invariants across the address/hostname/recipient/date parsers and `preview_line`); E3 (`FLOOR=205`); E4 (clippy ratchet at 0); E5 (a real SMTP conversation against a local sink, plus a silent-server timeout test — which **caught a real defect**: lettre's `.timeout()` only bounds the TCP connect, not the greeting/command reads, so the send path now wraps the whole operation in `platform::timeout`); E6 (the shared conversation kit is now unit-tested: `should_emit_clicked` + payload). Domain tests **206 → 214**; `nigig-uikit` gains its first 5 tests. |
|
| *(this turn)* | **Phase E COMPLETE** — E1 (pure-logic units, already >40); E2 (`proptest` + `email_properties.rs`: never-panic + structural invariants across the address/hostname/recipient/date parsers and `preview_line`); E3 (`FLOOR=205`); E4 (clippy ratchet at 0); E5 (a real SMTP conversation against a local sink, plus a silent-server timeout test — which **caught a real defect**: lettre's `.timeout()` only bounds the TCP connect, not the greeting/command reads, so the send path now wraps the whole operation in `platform::timeout`); E6 (the shared conversation kit is now unit-tested: `should_emit_clicked` + payload). Domain tests **206 → 214**; `nigig-uikit` gains its first 5 tests. |
|
||||||
| *(this turn)* | **§8 follow-up: close the "not verified" list.** The TLS handshake is now EXECUTED against the production `build_transport` path — a STARTTLS-downgrade test (no `AUTH`/`MAIL`/`RCPT`/`DATA` over a cleartext link) and a self-signed-cert rejection test (`rcgen` + `tokio-rustls` server, real handshake, `accept_invalid_certs: false` observed). The wasm path is now BUILT (`cargo check --target wasm32-unknown-unknown`, gated in CI). B1 and the test/clippy baselines are now runs/measurements, not reads. Domain tests **214 → 216**. |
|
|
||||||
|
|
||||||
**Phase 0 is complete.** All seven items done; 0.7 was fixed upstream.
|
**Phase 0 is complete.** All seven items done; 0.7 was fixed upstream.
|
||||||
|
|
||||||
|
|
@ -842,44 +841,25 @@ convincingly than any client-side fix can.
|
||||||
|
|
||||||
## 8. What I have not verified
|
## 8. What I have not verified
|
||||||
|
|
||||||
Stated plainly, because the point of this document is to be trusted. Every
|
Stated plainly, because the point of this document is to be trusted:
|
||||||
item that could be executed on a host has now been executed; the entries
|
|
||||||
below are the ones that genuinely cannot be, and exactly why.
|
|
||||||
|
|
||||||
- **The TLS handshake HAS now been executed — locally, not against a real
|
- **The SMTP conversation has been executed, but not the TLS handshake.**
|
||||||
provider.** Two tests exercise the production `build_transport` path over
|
Phase E5 runs a real send against a local mock SMTP sink (envelope, every
|
||||||
a real TCP + TLS socket:
|
recipient, and the DATA payload are asserted), and a silent-server test
|
||||||
- `a_starttls_downgrade_is_refused_without_sending_credentials` observes
|
pins the operation-timeout bound. What is STILL not executed: a TLS
|
||||||
the S3/T-E1 downgrade protection: a server that cannot STARTTLS receives
|
handshake against a real provider, and a MITM test. The S1/S3 analysis of
|
||||||
**no** `AUTH`, `MAIL FROM`, `RCPT TO` or `DATA` — credentials and the
|
`relay()`/`TlsParameters::new`/`TlsVersion` remains a read of lettre's
|
||||||
message never cross a cleartext link.
|
source, not an observed handshake — the sink is plaintext because the
|
||||||
- `a_self_signed_certificate_is_rejected` observes S1's certificate
|
production transport's TLS is pinned separately by `tls_mode_for_port`
|
||||||
validation: a server presenting a self-signed certificate is rejected by
|
and `build_transport_constructs_for_every_port`, not exercised here.
|
||||||
the transport (whose `accept_invalid_certs` is `false`), the active-MITM
|
|
||||||
scenario. `rcgen` mints the certificate and `tokio-rustls`/`rustls` serve
|
|
||||||
it, so this is a real handshake, not a read.
|
|
||||||
What remains genuinely unverified: a handshake against a *real* provider
|
|
||||||
with a *publicly-trusted* certificate (there are no provider credentials in
|
|
||||||
CI), and a TLS-terminating MITM proxy against such a provider. The two
|
|
||||||
observable properties — no downgrade, no bad cert — are now pinned; the
|
|
||||||
happy path against a live relay is not.
|
|
||||||
- **I got S1 wrong on the first pass** and wrote it up as critical credential
|
- **I got S1 wrong on the first pass** and wrote it up as critical credential
|
||||||
exposure before checking the library source. The corrected entry is in §1.
|
exposure before checking the library source. The corrected entry is in §1.
|
||||||
Flagging it because a document like this is worth nothing if you cannot tell
|
Flagging it because a document like this is worth nothing if you cannot tell
|
||||||
which claims were verified and which were assumed from a scary method name.
|
which claims were verified and which were assumed from a scary method name.
|
||||||
- **The wasm path has now been built.** `cargo check --target
|
- **The wasm path has not been built.** `call_email_api` is
|
||||||
wasm32-unknown-unknown -p nigig-core --no-default-features --features
|
`#[cfg(target_arch = "wasm32")]`; I compiled for host only.
|
||||||
async-rt` type-checks `call_email_api`, `api_payload`, `WasmFetchTransport`
|
- **B1 is a code read, not a run** — the binary does not compile, so the
|
||||||
and `set_email_api_url` — the credential-bearing proxy POST that a
|
multi-recipient failure is deduced from `Mailbox::parse`'s single-address
|
||||||
host-only build never sees. It is a compile check, not a run: the actual
|
contract rather than observed at runtime. It is unambiguous, but it is a read.
|
||||||
`fetch` behaviour still needs a browser, which CI does not provide.
|
- **Test/clippy baselines are not yet measured** for the ratchets in Phase E;
|
||||||
- **B1 is now a run, not a read.** The original note claimed "the binary
|
they must be captured from a real run, not guessed.
|
||||||
does not compile" (it did not, before Phase 0.1 fixed `main.rs`); the
|
|
||||||
multi-recipient failure was deduced from `Mailbox::parse`. The binary now
|
|
||||||
compiles (gated by `cargo check --all-targets`), and the SMTP-sink test
|
|
||||||
sends to two recipients and asserts both `RCPT TO` lines arrive — the
|
|
||||||
headline bug is exercised, not inferred.
|
|
||||||
- **Test/clippy baselines are now measured.** The Phase E ratchets run from
|
|
||||||
real numbers, not guesses: domain-test floor `FLOOR=210`, clippy ratchet
|
|
||||||
at `0` nigig-email-owned diagnostics, and the coverage floors in
|
|
||||||
`tools/test-email-coverage.sh`.
|
|
||||||
|
|
|
||||||
|
|
@ -63,18 +63,6 @@ keyring = { version = "4", optional = true }
|
||||||
# E2: property-test the address/date parsers — never panic on arbitrary
|
# E2: property-test the address/date parsers — never panic on arbitrary
|
||||||
# input. Same dev-dependency the SMS crate already uses.
|
# input. Same dev-dependency the SMS crate already uses.
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
# §8 follow-up: execute the TLS handshake against a local server (the
|
|
||||||
# assessment's "TLS handshake never executed" gap). rcgen mints a
|
|
||||||
# self-signed certificate; tokio-rustls/rustls serve it so the production
|
|
||||||
# transport's certificate validation (accept_invalid_certs: false) is
|
|
||||||
# observed, not just read from lettre's source.
|
|
||||||
rcgen = "0.13"
|
|
||||||
tokio-rustls = "0.26"
|
|
||||||
# rustls directly, so the test can build the SERVER side of the handshake
|
|
||||||
# (tokio-rustls only re-exports it through a dep). `ring` matches the
|
|
||||||
# provider lettre selects, so `with_single_cert` can sign an ECDSA key.
|
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
|
||||||
tokio = { version = "1", features = ["rt", "macros", "net", "time", "io-util", "sync"] }
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
|
|
||||||
|
|
@ -1295,7 +1295,7 @@ mod tests {
|
||||||
|
|
||||||
/// A tiny SMTP sink that speaks just enough of the protocol to receive
|
/// A tiny SMTP sink that speaks just enough of the protocol to receive
|
||||||
/// one message and record the envelope + data. Plaintext on purpose
|
/// one message and record the envelope + data. Plaintext on purpose
|
||||||
/// (the production transport's TLS is pinned separately).
|
/// (the production transport's TLS is pinned elsewhere).
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
mod smtp_sink {
|
mod smtp_sink {
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
|
@ -1307,18 +1307,13 @@ mod tests {
|
||||||
pub mail_from: Vec<String>,
|
pub mail_from: Vec<String>,
|
||||||
pub rcpt_to: Vec<String>,
|
pub rcpt_to: Vec<String>,
|
||||||
pub data: String,
|
pub data: String,
|
||||||
/// Every command line the client sent, verbatim. Lets a test
|
|
||||||
/// assert that a command (AUTH, MAIL FROM, …) was NEVER sent.
|
|
||||||
pub commands: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bind an ephemeral port and serve one SMTP transaction on a
|
/// Bind an ephemeral port and serve one SMTP transaction on a
|
||||||
/// background thread. `ehlo_reply` is the multi-line EHLO response
|
/// background thread. The captured conversation is reported as soon
|
||||||
/// (must end in `\r\n`); omit STARTTLS to simulate a server that
|
/// as the DATA payload is complete — NOT on QUIT, because a pooled
|
||||||
/// cannot upgrade. The captured conversation is reported as soon as
|
|
||||||
/// the DATA payload is complete — NOT on QUIT, because a pooled
|
|
||||||
/// client may hold the connection open after the send.
|
/// client may hold the connection open after the send.
|
||||||
pub fn start_with_caps(ehlo_reply: &'static str) -> (u16, mpsc::Receiver<Captured>) {
|
pub fn start() -> (u16, mpsc::Receiver<Captured>) {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
let port = listener.local_addr().expect("addr").port();
|
let port = listener.local_addr().expect("addr").port();
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
@ -1342,9 +1337,8 @@ mod tests {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let cmd = line.trim_end().to_string();
|
let cmd = line.trim_end().to_string();
|
||||||
captured.commands.push(cmd.clone());
|
|
||||||
if cmd.starts_with("EHLO") || cmd.starts_with("HELO") {
|
if cmd.starts_with("EHLO") || cmd.starts_with("HELO") {
|
||||||
respond(ehlo_reply).unwrap();
|
respond("250-mock.example\r\n250 8BITMIME\r\n").unwrap();
|
||||||
} else if cmd.starts_with("MAIL FROM:") {
|
} else if cmd.starts_with("MAIL FROM:") {
|
||||||
captured.mail_from.push(cmd);
|
captured.mail_from.push(cmd);
|
||||||
respond("250 Ok\r\n").unwrap();
|
respond("250 Ok\r\n").unwrap();
|
||||||
|
|
@ -1380,11 +1374,6 @@ mod tests {
|
||||||
|
|
||||||
(port, rx)
|
(port, rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A sink that does NOT advertise STARTTLS.
|
|
||||||
pub fn start() -> (u16, mpsc::Receiver<Captured>) {
|
|
||||||
start_with_caps("250-mock.example\r\n250 8BITMIME\r\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A message sent through `build_email_message` + a transport reaches
|
/// A message sent through `build_email_message` + a transport reaches
|
||||||
|
|
@ -1500,213 +1489,4 @@ mod tests {
|
||||||
"the error should name the timeout, got {err:?}"
|
"the error should name the timeout, got {err:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- §8 follow-up: the TLS handshake, executed ----------------------
|
|
||||||
//
|
|
||||||
// The assessment's "What I have not verified" listed the TLS handshake
|
|
||||||
// and a MITM test as gaps: the S1/S3 analysis of `relay()` /
|
|
||||||
// `TlsParameters::new` was a read of lettre's source, not an observed
|
|
||||||
// handshake. These two tests execute it, against the PRODUCTION
|
|
||||||
// `build_transport` path (not `builder_dangerous`).
|
|
||||||
|
|
||||||
/// The STARTTLS downgrade attack (S3/T-E1), observed.
|
|
||||||
///
|
|
||||||
/// A server that does not advertise STARTTLS must cause the production
|
|
||||||
/// transport (port → `starttls_relay`, `Tls::Required`) to fail WITHOUT
|
|
||||||
/// sending `AUTH`, `MAIL FROM`, `RCPT TO` or `DATA` — credentials and
|
|
||||||
/// the message never leave the device over a cleartext link. This is
|
|
||||||
/// the property lettre's docs promise ("no credentials or emails will
|
|
||||||
/// be sent to the server, protecting from downgrade attacks") and the
|
|
||||||
/// assessment had only read, not observed.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
#[test]
|
|
||||||
fn a_starttls_downgrade_is_refused_without_sending_credentials() {
|
|
||||||
// No STARTTLS capability.
|
|
||||||
let (port, rx) =
|
|
||||||
smtp_sink::start_with_caps("250-mock.example\r\n250-SIZE 100000\r\n250 8BITMIME\r\n");
|
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.expect("runtime");
|
|
||||||
|
|
||||||
let result = rt.block_on(async {
|
|
||||||
let config = SmtpConfig {
|
|
||||||
server: "127.0.0.1".into(),
|
|
||||||
port,
|
|
||||||
username: "jane@example.com".into(),
|
|
||||||
password: Secret::new("hunter2"),
|
|
||||||
from: "jane@example.com".into(),
|
|
||||||
};
|
|
||||||
let email =
|
|
||||||
build_email_message(&config, "a@x.com", "Hi", "Body").expect("message builds");
|
|
||||||
// The PRODUCTION path: an ephemeral (non-465) port selects the
|
|
||||||
// StartTls arm, which requires the upgrade.
|
|
||||||
let creds =
|
|
||||||
Credentials::new(config.username.clone(), config.password.expose().to_owned());
|
|
||||||
let transport = build_transport(&config, creds).expect("transport constructs");
|
|
||||||
send_bounded(&transport, email, std::time::Duration::from_secs(3)).await
|
|
||||||
});
|
|
||||||
|
|
||||||
assert!(result.is_err(), "must refuse when STARTTLS is unavailable");
|
|
||||||
|
|
||||||
let captured = rx
|
|
||||||
.recv_timeout(std::time::Duration::from_secs(10))
|
|
||||||
.expect("the sink should report the conversation");
|
|
||||||
|
|
||||||
// The decisive assertions: nothing sensitive was sent.
|
|
||||||
assert!(
|
|
||||||
!captured.commands.iter().any(|c| c.starts_with("AUTH")),
|
|
||||||
"credentials were sent over a link that never upgraded to TLS: {:?}",
|
|
||||||
captured.commands
|
|
||||||
);
|
|
||||||
assert!(captured.mail_from.is_empty(), "MAIL FROM sent without TLS");
|
|
||||||
assert!(captured.rcpt_to.is_empty(), "RCPT TO sent without TLS");
|
|
||||||
assert!(captured.data.is_empty(), "message body sent without TLS");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Certificate validation (S1), observed.
|
|
||||||
///
|
|
||||||
/// A server that upgrades to TLS but presents a self-signed
|
|
||||||
/// certificate must be REJECTED by the production transport, whose
|
|
||||||
/// `TlsParameters::new` sets `accept_invalid_certs: false`. This is the
|
|
||||||
/// "active MITM" scenario (a server impersonating the relay) and it is
|
|
||||||
/// the exact claim the assessment had to correct itself on after
|
|
||||||
/// reading `builder_dangerous` — now it is executed, not read.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
#[test]
|
|
||||||
fn a_self_signed_certificate_is_rejected() {
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.expect("runtime");
|
|
||||||
|
|
||||||
rt.block_on(async {
|
|
||||||
// rustls 0.23 with only the `ring` feature does not install a
|
|
||||||
// process-wide default crypto provider; the client (lettre) may
|
|
||||||
// or may not have. Install it (idempotent) so `ServerConfig`
|
|
||||||
// can sign with the generated ECDSA key.
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
// Mint a self-signed certificate for "localhost". It is NOT in
|
|
||||||
// the client's root store (webpki-roots), so a validating
|
|
||||||
// client must reject it.
|
|
||||||
let certified = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
|
||||||
.expect("mint self-signed cert");
|
|
||||||
let server_config = rustls::ServerConfig::builder()
|
|
||||||
.with_no_client_auth()
|
|
||||||
.with_single_cert(
|
|
||||||
vec![certified.cert.der().clone()],
|
|
||||||
rustls::pki_types::PrivateKeyDer::Pkcs8(
|
|
||||||
certified.key_pair.serialize_der().into(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.expect("server config");
|
|
||||||
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config));
|
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
||||||
.await
|
|
||||||
.expect("bind");
|
|
||||||
let port = listener.local_addr().expect("addr").port();
|
|
||||||
|
|
||||||
// A server that advertises STARTTLS, upgrades, and serves the
|
|
||||||
// self-signed cert. It records whether it ever saw AUTH (it
|
|
||||||
// must not — the client bails at the failed handshake).
|
|
||||||
let saw_auth = Arc::new(AtomicBool::new(false));
|
|
||||||
let saw_auth_server = saw_auth.clone();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (mut stream, _) = listener.accept().await.expect("accept");
|
|
||||||
stream
|
|
||||||
.write_all(b"220 mock.example ESMTP\r\n")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
loop {
|
|
||||||
buf.clear();
|
|
||||||
if read_smtp_line(&mut stream, &mut buf).await.unwrap() == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let cmd = String::from_utf8_lossy(&buf).trim_end().to_string();
|
|
||||||
if cmd.starts_with("EHLO") || cmd.starts_with("HELO") {
|
|
||||||
stream
|
|
||||||
.write_all(b"250-mock.example\r\n250-STARTTLS\r\n250 8BITMIME\r\n")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
} else if cmd == "STARTTLS" {
|
|
||||||
stream
|
|
||||||
.write_all(b"220 Ready to start TLS\r\n")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// Upgrade to TLS; the client must reject our cert.
|
|
||||||
let handshake = acceptor.accept(stream).await;
|
|
||||||
assert!(
|
|
||||||
handshake.is_err(),
|
|
||||||
"the client accepted a self-signed certificate!"
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
} else if cmd.starts_with("AUTH") {
|
|
||||||
saw_auth_server.store(true, Ordering::Relaxed);
|
|
||||||
} else {
|
|
||||||
stream.write_all(b"250 Ok\r\n").await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let config = SmtpConfig {
|
|
||||||
server: "127.0.0.1".into(),
|
|
||||||
port,
|
|
||||||
username: "jane@example.com".into(),
|
|
||||||
password: Secret::new("hunter2"),
|
|
||||||
from: "jane@example.com".into(),
|
|
||||||
};
|
|
||||||
let email =
|
|
||||||
build_email_message(&config, "a@x.com", "Hi", "Body").expect("message builds");
|
|
||||||
let creds =
|
|
||||||
Credentials::new(config.username.clone(), config.password.expose().to_owned());
|
|
||||||
// The PRODUCTION path: ephemeral port → starttls_relay (Tls::Required).
|
|
||||||
let transport = build_transport(&config, creds).expect("transport constructs");
|
|
||||||
let result = send_bounded(&transport, email, std::time::Duration::from_secs(5)).await;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"a self-signed certificate must be rejected, got {result:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The server's own assertion (handshake rejected) already ran;
|
|
||||||
// await it and confirm the client never reached AUTH.
|
|
||||||
server.await.expect("server task");
|
|
||||||
assert!(
|
|
||||||
!saw_auth.load(Ordering::Relaxed),
|
|
||||||
"the client sent AUTH despite the failed handshake"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read one `\r\n`-terminated SMTP line without owning the stream, so
|
|
||||||
/// the caller can hand the stream to the TLS acceptor afterwards.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
async fn read_smtp_line(
|
|
||||||
stream: &mut tokio::net::TcpStream,
|
|
||||||
buf: &mut Vec<u8>,
|
|
||||||
) -> std::io::Result<usize> {
|
|
||||||
use tokio::io::AsyncReadExt;
|
|
||||||
buf.clear();
|
|
||||||
let mut byte = [0u8; 1];
|
|
||||||
loop {
|
|
||||||
match stream.read(&mut byte).await? {
|
|
||||||
0 => return Ok(0),
|
|
||||||
_ => {
|
|
||||||
buf.push(byte[0]);
|
|
||||||
if buf.ends_with(b"\r\n") {
|
|
||||||
return Ok(buf.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue