nigig-org/crates/apps/nigig-email/THREAT_MODEL.md
nigig-ci 3786e7c1cf
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
docs(email): threat model, and mark Phase A complete (A6)
New crates/apps/nigig-email/THREAT_MODEL.md. Separate from the root
THREAT_MODEL.md, which is Nigig-Pay's and shares no assets with this
feature.

Nine threats with the control named for each, so a reader can check the
claim rather than take it on trust. The two that matter:

  T-E2 (password leaked by our own code) was LIVE, not hypothetical --
  SmtpConfig derived Serialize over a plaintext String and the whole
  struct was POSTed on wasm.

  T-E7 (DoS via a hostile message) is the SMS A3 bug class. One inbound
  message containing emoji took down the SMS list on every frame until it
  was deleted; email bodies are more hostile, not less.

Sections that exist specifically to avoid overclaiming:

  * "Residual" notes on every mitigation. Secret does not zero on drop.
    We trust the platform root store; no certificate pinning. Header
    injection is handled by lettre, NOT by us -- which means the C1d proxy
    backend, which does not go through lettre, must sanitise or T-E4
    becomes unmitigated.
  * "What has not been tested": no live SMTP server has been contacted,
    the wasm path has never been built, and no IMAP code exists, so
    T-E1/T-E2 cover the SMTP direction only.
  * Four open risks ranked, each tied to a plan item, including two
    (proxy auth, keystore storage) that MUST land with C1d/C1f rather
    than after -- the proxy is only safer than on-device IMAP if its token
    is revocable and scoped.

Marks A1-A6 done in the plan. Phase A is complete.
2026-08-16 19:22:40 +00:00

9.8 KiB

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
fromusername 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.