Compare commits

..

No commits in common. "189377a3a392e3e6ef051ae1f6b2d1cdd0e7187f" and "1220f89fc6f8ef427df505f72a156e8f6fbfd925" have entirely different histories.

5 changed files with 25 additions and 385 deletions

View file

@ -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
View file

@ -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"

View file

@ -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`.

View file

@ -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"

View file

@ -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());
}
}
}
}
}
} }