Compare commits
2 commits
1220f89fc6
...
189377a3a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 189377a3a3 | |||
| 216202a90a |
5 changed files with 385 additions and 25 deletions
|
|
@ -410,11 +410,12 @@ jobs:
|
|||
# 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;
|
||||
# 195 after the Phase C/D completion; 214 after Phase E (proptest +
|
||||
# the SMTP sink integration tests).
|
||||
# the SMTP sink integration tests); 216 after the §8 TLS handshake
|
||||
# tests.
|
||||
- name: The email domain test suite must not shrink
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FLOOR=205
|
||||
FLOOR=210
|
||||
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
|
||||
|
|
@ -425,6 +426,20 @@ jobs:
|
|||
- name: The IMAP and keystore features must compile
|
||||
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
|
||||
# whole-domain floor plus per-file floors on the files that have
|
||||
# actually harboured bugs (the SMTP password serialisation, the bulk
|
||||
|
|
|
|||
93
Cargo.lock
generated
93
Cargo.lock
generated
|
|
@ -395,6 +395,29 @@ version = "1.5.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
|
|
@ -594,6 +617,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
|
|
@ -700,6 +725,15 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
|
|
@ -939,6 +973,12 @@ name = "downcast-rs"
|
|||
version = "1.2.1"
|
||||
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]]
|
||||
name = "either"
|
||||
version = "1.17.0"
|
||||
|
|
@ -1161,6 +1201,12 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.34"
|
||||
|
|
@ -1901,6 +1947,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
|
|
@ -2986,14 +3042,17 @@ dependencies = [
|
|||
"postcard",
|
||||
"proptest",
|
||||
"rand 0.8.7",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"robius-directories",
|
||||
"robius-location",
|
||||
"robius-open",
|
||||
"robius-ussd",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
|
@ -3744,6 +3803,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -4146,6 +4215,19 @@ dependencies = [
|
|||
"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]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
@ -4479,6 +4561,7 @@ version = "0.23.43"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log 0.4.33",
|
||||
"once_cell 1.21.4",
|
||||
"ring",
|
||||
|
|
@ -4504,6 +4587,7 @@ version = "0.103.14"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
|
|
@ -6028,6 +6112,15 @@ version = "0.6.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||
dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
|
|
|||
|
|
@ -548,6 +548,7 @@ 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)* | **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)* | **§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.
|
||||
|
||||
|
|
@ -841,25 +842,44 @@ convincingly than any client-side fix can.
|
|||
|
||||
## 8. What I have not verified
|
||||
|
||||
Stated plainly, because the point of this document is to be trusted:
|
||||
Stated plainly, because the point of this document is to be trusted. Every
|
||||
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 SMTP conversation has been executed, but not the TLS handshake.**
|
||||
Phase E5 runs a real send against a local mock SMTP sink (envelope, every
|
||||
recipient, and the DATA payload are asserted), and a silent-server test
|
||||
pins the operation-timeout bound. What is STILL not executed: a TLS
|
||||
handshake against a real provider, and a MITM test. The S1/S3 analysis of
|
||||
`relay()`/`TlsParameters::new`/`TlsVersion` remains a read of lettre's
|
||||
source, not an observed handshake — the sink is plaintext because the
|
||||
production transport's TLS is pinned separately by `tls_mode_for_port`
|
||||
and `build_transport_constructs_for_every_port`, not exercised here.
|
||||
- **The TLS handshake HAS now been executed — locally, not against a real
|
||||
provider.** Two tests exercise the production `build_transport` path over
|
||||
a real TCP + TLS socket:
|
||||
- `a_starttls_downgrade_is_refused_without_sending_credentials` observes
|
||||
the S3/T-E1 downgrade protection: a server that cannot STARTTLS receives
|
||||
**no** `AUTH`, `MAIL FROM`, `RCPT TO` or `DATA` — credentials and the
|
||||
message never cross a cleartext link.
|
||||
- `a_self_signed_certificate_is_rejected` observes S1's certificate
|
||||
validation: a server presenting a self-signed certificate is rejected by
|
||||
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
|
||||
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
|
||||
which claims were verified and which were assumed from a scary method name.
|
||||
- **The wasm path has not been built.** `call_email_api` is
|
||||
`#[cfg(target_arch = "wasm32")]`; I compiled for host only.
|
||||
- **B1 is a code read, not a run** — the binary does not compile, so the
|
||||
multi-recipient failure is deduced from `Mailbox::parse`'s single-address
|
||||
contract rather than observed at runtime. It is unambiguous, but it is a read.
|
||||
- **Test/clippy baselines are not yet measured** for the ratchets in Phase E;
|
||||
they must be captured from a real run, not guessed.
|
||||
- **The wasm path has now been built.** `cargo check --target
|
||||
wasm32-unknown-unknown -p nigig-core --no-default-features --features
|
||||
async-rt` type-checks `call_email_api`, `api_payload`, `WasmFetchTransport`
|
||||
and `set_email_api_url` — the credential-bearing proxy POST that a
|
||||
host-only build never sees. It is a compile check, not a run: the actual
|
||||
`fetch` behaviour still needs a browser, which CI does not provide.
|
||||
- **B1 is now a run, not a read.** The original note claimed "the binary
|
||||
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,6 +63,18 @@ keyring = { version = "4", optional = true }
|
|||
# E2: property-test the address/date parsers — never panic on arbitrary
|
||||
# input. Same dev-dependency the SMS crate already uses.
|
||||
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]
|
||||
wasm-bindgen = "0.2"
|
||||
|
|
|
|||
|
|
@ -1295,7 +1295,7 @@ mod tests {
|
|||
|
||||
/// A tiny SMTP sink that speaks just enough of the protocol to receive
|
||||
/// one message and record the envelope + data. Plaintext on purpose
|
||||
/// (the production transport's TLS is pinned elsewhere).
|
||||
/// (the production transport's TLS is pinned separately).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod smtp_sink {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
|
|
@ -1307,13 +1307,18 @@ mod tests {
|
|||
pub mail_from: Vec<String>,
|
||||
pub rcpt_to: Vec<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
|
||||
/// background thread. The captured conversation is reported as soon
|
||||
/// as the DATA payload is complete — NOT on QUIT, because a pooled
|
||||
/// background thread. `ehlo_reply` is the multi-line EHLO response
|
||||
/// (must end in `\r\n`); omit STARTTLS to simulate a server that
|
||||
/// 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.
|
||||
pub fn start() -> (u16, mpsc::Receiver<Captured>) {
|
||||
pub fn start_with_caps(ehlo_reply: &'static str) -> (u16, mpsc::Receiver<Captured>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let port = listener.local_addr().expect("addr").port();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
|
@ -1337,8 +1342,9 @@ mod tests {
|
|||
break;
|
||||
}
|
||||
let cmd = line.trim_end().to_string();
|
||||
captured.commands.push(cmd.clone());
|
||||
if cmd.starts_with("EHLO") || cmd.starts_with("HELO") {
|
||||
respond("250-mock.example\r\n250 8BITMIME\r\n").unwrap();
|
||||
respond(ehlo_reply).unwrap();
|
||||
} else if cmd.starts_with("MAIL FROM:") {
|
||||
captured.mail_from.push(cmd);
|
||||
respond("250 Ok\r\n").unwrap();
|
||||
|
|
@ -1374,6 +1380,11 @@ mod tests {
|
|||
|
||||
(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
|
||||
|
|
@ -1489,4 +1500,213 @@ mod tests {
|
|||
"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