From 216202a90acaf712ccf5cd1ce490dfb561f1eb8b Mon Sep 17 00:00:00 2001 From: andodeki Date: Mon, 17 Aug 2026 09:38:47 +0000 Subject: [PATCH 1/2] =?UTF-8?q?test(email):=20execute=20the=20TLS=20handsh?= =?UTF-8?q?ake,=20not=20just=20read=20it=20(=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, never observed. Two tests now execute the PRODUCTION build_transport path over a real TCP + TLS socket: - a_starttls_downgrade_is_refused_without_sending_credentials: a server that cannot STARTTLS receives no AUTH/MAIL FROM/RCPT TO/DATA — credentials and the message never cross a cleartext link (S3/T-E1 downgrade protection). - a_self_signed_certificate_is_rejected: a server presenting a self-signed cert (minted with rcgen, served by tokio-rustls/rustls) is rejected by the transport, whose accept_invalid_certs is false — the active-MITM scenario (S1), observed rather than assumed. The SMTP sink now records every command line so a test can assert a command was never sent. Domain tests 214 -> 216. --- Cargo.lock | 93 +++++++++++ crates/nigig-core/Cargo.toml | 12 ++ crates/nigig-core/src/email_worker.rs | 230 +++++++++++++++++++++++++- 3 files changed, 330 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c53988..1ab0d82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/nigig-core/Cargo.toml b/crates/nigig-core/Cargo.toml index c767d04..c78eaa8 100644 --- a/crates/nigig-core/Cargo.toml +++ b/crates/nigig-core/Cargo.toml @@ -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" diff --git a/crates/nigig-core/src/email_worker.rs b/crates/nigig-core/src/email_worker.rs index 3995ce1..2693089 100644 --- a/crates/nigig-core/src/email_worker.rs +++ b/crates/nigig-core/src/email_worker.rs @@ -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, pub rcpt_to: Vec, 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, } /// 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) { + pub fn start_with_caps(ehlo_reply: &'static str) -> (u16, mpsc::Receiver) { 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) { + 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, + ) -> std::io::Result { + 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()); + } + } + } + } + } } From 189377a3a392e3e6ef051ae1f6b2d1cdd0e7187f Mon Sep 17 00:00:00 2001 From: andodeki Date: Mon, 17 Aug 2026 09:38:54 +0000 Subject: [PATCH 2/2] =?UTF-8?q?ci(email):=20build=20the=20wasm=20path;=20d?= =?UTF-8?q?ocument=20the=20closed=20=C2=A78=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit email.yml: install the wasm32-unknown-unknown target and check the email domain's credential-bearing wasm half (call_email_api, WasmFetchTransport, set_email_api_url) so a browser-only breakage cannot reach main unseen. The domain test floor ratchets 205 -> 210. The review doc's §8 is rewritten: the TLS handshake, the wasm build, B1 and the test/clippy baselines are now executed/measured; the only entries left are the ones that genuinely cannot run in CI (a live relay's cert, a browser's fetch), stated with their exact reasons. --- .forgejo/workflows/email.yml | 19 +++++++- REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md | 54 +++++++++++++++------- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/.forgejo/workflows/email.yml b/.forgejo/workflows/email.yml index 59dd4fe..8559fec 100644 --- a/.forgejo/workflows/email.yml +++ b/.forgejo/workflows/email.yml @@ -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 diff --git a/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md b/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md index f949b4f..ac748e4 100644 --- a/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md +++ b/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md @@ -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`.