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
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.
216 lines
9.8 KiB
Markdown
216 lines
9.8 KiB
Markdown
# 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.
|