# nigig-email — Threat Model Scope: `crates/apps/nigig-email` and the email modules it depends on in `crates/nigig-core` (`email_account`, `email_store`, `email_worker`, `secret`). Separate from the root `THREAT_MODEL.md`, which is Nigig-Pay's and shares no assets with this feature. Written as part of Phase A of `REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md`. Each mitigation names the control, so a reader can check the claim rather than take it on trust. --- ## 1. Assets | # | Asset | Why it matters | |---|---|---| | A1 | **The user's mailbox password or app password** | The highest-value secret in the app. For most people email is the password-reset channel for every other account they own, so losing it is worse than losing a payment credential. | | A2 | Message bodies and subjects | Private correspondence. May contain financial and identity data. | | A3 | The sender's address and contact graph | Who the user talks to is sensitive even when content is not. | | A4 | SMTP/IMAP server names and ports | Low value alone; useful for targeting. | | A5 | The proxy API endpoint and its token (once C1d lands) | A revocable stand-in for A1 — strictly safer, because revoking it does not require a password change. | ## 2. Trust boundaries ``` ┌─ device ───────────────────────────────────┐ │ UI widgets ──▶ nigig-core (validation) │ │ │ │ │ ▼ │ │ Secret (in memory only) │ └──────────────────────┼─────────────────────┘ │ ← boundary 1: the network ┌───────────────┴────────────────┐ ▼ ▼ SMTP relay (native) Proxy API (wasm) TLS required https required ``` Boundary 1 is the one that matters. Everything above it is our code; everything below is a third party we authenticate to with A1. ## 3. Threats and mitigations ### T-E1 — Password captured in transit (CRITICAL) *An attacker on the network reads the credential during AUTH.* Mitigated: - Every port maps to a mandatory TLS mode; `tls_mode_for_port` has **no cleartext arm**. Port 465 → implicit TLS via `relay()`; everything else → `starttls_relay`, which uses `Tls::Required` and so aborts **before** sending AUTH if the upgrade fails. That is what defeats a downgrade. - Certificate and hostname validation are on (`TlsParameters::new` sets `accept_invalid_certs: false`, `accept_invalid_hostnames: false`, TLS 1.2 floor). The `dangerous_*` opt-outs are never called. - A CI gate fails the build if `Tls::None` or `Tls::Opportunistic` appears, or if `tls_mode_for_port` is deleted. - On wasm, `set_email_api_url` rejects anything that is not same-origin or `https://`, **including protocol-relative `//host/path`**, which is http on an http page and is easy to mistake for a relative path. Residual: we trust the platform root store. A device with an attacker- installed CA can still intercept. Certificate pinning is not implemented. ### T-E2 — Password leaked by our own code (CRITICAL) *The credential escapes through a log, a crash dump, or a serialised struct — not through an attack, but through carelessness.* This was **live**, not hypothetical. `SmtpConfig` derived `Serialize` over a plaintext `String`, and on wasm the whole struct was `serde_json`-encoded and POSTed. Any reverse proxy or APM tool logging request bodies captured it, and nothing in the code said so. Mitigated: - `Secret` (`crates/nigig-core/src/secret.rs`): `Debug` always renders `Secret("***")`, with no verbose mode. `Display` is **not implemented**, so `format!("{s}")` will not compile either. - `Serialize`/`Deserialize` are **not implemented** on `Secret` and have been **removed from `SmtpConfig`**. A struct holding a secret cannot be serialised wholesale; the compiler stops it. The wasm request body is now assembled field by field, so `password` appears at exactly **one line** and "what leaves the device?" is answerable by reading one function. - `EmailAccount` — the persistable half — has **no password field at all**. Two controls: a unit test asserting the serialised form contains neither the secret nor a field named `password`, and a CI gate on the struct body in case someone deletes the test. - `expose()` is the single reader, named to be conspicuous in review. - The secret is cleared the moment a connection is known to have failed. **Residual, and worth stating plainly:** `Secret` does **not** zero its buffer on drop. Without a `zeroize`-style crate the plaintext can persist in freed heap memory, and on a device with swap it can reach disk. The type is a leak-through-code control, not an anti-forensics one. ### T-E3 — Password persisted in cleartext (HIGH) *A "remember my settings" feature writes A1 to a JSON file.* Not currently possible: nothing persists the credential, and `Secret` cannot be serialised. That is presently **by omission** rather than by design — the safest property of the original code was that it never implemented storage. **Open.** Plan item **C1f** must use the platform keystore. The in-repo precedent is `robius-sms`'s `SmsScheduleCrypto.java` — AES-256-GCM via `AndroidKeyStore`, failing closed. Until then, a restart requires re-entering the password, which is the correct trade. ### T-E4 — Header or content injection (MEDIUM) *`\r\n` in a subject or recipient forges headers (BCC, Reply-To).* Partly mitigated: - `lettre`'s `Message::builder()` encodes headers, so classic CRLF injection is handled **by the library**, not by us. - `validate_send` bounds subject at 998 bytes (RFC 5322) and body at 5 MB, both before any network call. Residual: we do not reject control characters in the subject ourselves. We rely on `lettre`. If the transport is ever swapped — e.g. for the C1d proxy, which does **not** go through `lettre` — this becomes unmitigated and the proxy body builder must sanitise. ### T-E5 — Sender spoofing (MEDIUM, not fixable here) *A user sets `from` to an address they do not control.* **Cannot be mitigated client-side.** Whether `from` may differ from the authenticated identity is the relay's policy (SPF/DKIM/DMARC), enforced server-side. Same conclusion as SMS **E11**. What we can do, and do not yet: warn when `from` differs from `username`, which is the most common cause of a silent provider rejection. **Open**, folded into A4 follow-up. ### T-E6 — Unauthenticated proxy endpoint (MEDIUM) *Any script on the page drives the send endpoint using the user's session.* Partly mitigated: the endpoint must be https or same-origin (T-E1). **Open.** The POST still carries no auth token, no CSRF token and no request signing. This is a **C1d requirement**, not an afterthought: the proxy is only safer than on-device IMAP if the token is revocable and scoped. ### T-E7 — Denial of service via a hostile message (MEDIUM) *A crafted message body crashes the client on render.* Mitigated: - `preview_line` slices on `char_indices`, never byte offsets. This is the SMS **A3** bug class: `&s[..n]` panics when `n` is not a UTF-8 boundary, and one inbound message containing emoji or non-Latin text took down the whole SMS list on **every frame** until deleted. Email bodies are more hostile, not less — arbitrary MIME from anyone who knows the address. - Tested against emoji, Swahili, Arabic, Japanese and deliberately misaligned mixed text, which is the case that actually triggers it (uniform emoji happens to land on a boundary). - A CI gate rejects new byte-offset slicing in the email text helpers. - Timestamp formatting is total over `i64`, including `MIN`/`MAX`, because it runs inside `draw_walk` per visible row. ### T-E8 — Unbounded network operation (LOW) *A black-holed relay hangs the UI.* Mitigated: a 20-second SMTP timeout, shorter than `lettre`'s 60s-per- command default. `validate_send` also refuses obviously incomplete configs locally, so the common failure costs no round trip. Residual: no cancel button, and tasks are fire-and-forget with no retained `JoinHandle`. Plan item **B6**. ### T-E9 — Accidental duplicate send (LOW) *A double tap sends twice — billed, irreversible, to a human.* **Open.** No in-flight guard and no confirmation. `robius-sms` solved this with `BULK_SEND_IN_FLIGHT` (an `AtomicBool` swap) plus two-tap confirmation; email has the same irreversibility and neither control. Plan item **B5**. ## 4. Open risks, ranked | Risk | Plan item | Why it is still open | |---|---|---| | No keystore-backed credential storage | C1f | Needs platform work; deliberately blocks "remember me" until then | | Proxy endpoint unauthenticated | C1d | Must land with the proxy backend, not after | | `Secret` does not zero on drop | — | Needs a `zeroize` dependency; not yet justified, but do not claim the protection | | No duplicate-send guard | B5 | Small; next phase | | No cancel on a running send | B6 | Small; next phase | | `from` ≠ `username` not warned | A4 follow-up | Cheap, high-value diagnostic | | No certificate pinning | — | Accepted: platform root store is the norm for mail clients | ## 5. What has not been tested Stated because a threat model that overclaims is worse than none: - **No live SMTP server has been contacted.** The TLS analysis is a read of `lettre` 0.11.23's source, plus unit tests on our own port→mode mapping. No interception has been attempted. - **The wasm path has not been built or run.** `call_email_api` is `#[cfg(target_arch = "wasm32")]`; `email_api_url_is_safe` is host-tested, but the fetch code around it is not. - **No IMAP code exists yet**, so T-E1/T-E2 cover the SMTP direction only.