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.
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_porthas no cleartext arm. Port 465 → implicit TLS viarelay(); everything else →starttls_relay, which usesTls::Requiredand so aborts before sending AUTH if the upgrade fails. That is what defeats a downgrade. - Certificate and hostname validation are on (
TlsParameters::newsetsaccept_invalid_certs: false,accept_invalid_hostnames: false, TLS 1.2 floor). Thedangerous_*opt-outs are never called. - A CI gate fails the build if
Tls::NoneorTls::Opportunisticappears, or iftls_mode_for_portis deleted. - On wasm,
set_email_api_urlrejects anything that is not same-origin orhttps://, 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):Debugalways rendersSecret("***"), with no verbose mode.Displayis not implemented, soformat!("{s}")will not compile either.Serialize/Deserializeare not implemented onSecretand have been removed fromSmtpConfig. A struct holding a secret cannot be serialised wholesale; the compiler stops it. The wasm request body is now assembled field by field, sopasswordappears 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 namedpassword, 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'sMessage::builder()encodes headers, so classic CRLF injection is handled by the library, not by us.validate_sendbounds 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_lineslices onchar_indices, never byte offsets. This is the SMS A3 bug class:&s[..n]panics whennis 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, includingMIN/MAX, because it runs insidedraw_walkper 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
lettre0.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_apiis#[cfg(target_arch = "wasm32")];email_api_url_is_safeis 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.