Skip to content

Email infra (Brevo SMTP via nodemailer) + F-2 partial mitigation for issue #27#33

Merged
pulkitpareek18 merged 1 commit into
mainfrom
dev
May 14, 2026
Merged

Email infra (Brevo SMTP via nodemailer) + F-2 partial mitigation for issue #27#33
pulkitpareek18 merged 1 commit into
mainfrom
dev

Conversation

@pulkitpareek18
Copy link
Copy Markdown
Collaborator

Wires up the first real email infrastructure in the repo, AND closes the timing-oracle half of issue #27 (F-2 from PR #22 security review).

Architecture (ADR-0005)

```
src/services/
email.ts ← sendMail wrapper, never throws

└── nodemailer 8.0.7 ← MIT-0, generic SMTP

└── Brevo SMTP ← provider, swappable via .env
```

Picked nodemailer over emailjs + provider-specific SDKs (Postmark, SES, SendGrid). The four alternatives are surveyed in `adr/0005-adopt-nodemailer-for-smtp.md`. Generic SMTP keeps the provider choice in `.env` — Brevo today, easy to swap later.

What ships

Email infrastructure

File What it does
`adr/0005-adopt-nodemailer-for-smtp.md` Decision record with full alternatives survey + operational pre-reqs + threat model delta
`src/services/email.ts` `sendMail({to, subject, html, text})` wrapper. Never throws. Returns `{ ok: true, messageId }` / `{ ok: false, error }` / `{ ok: false, skipped: true }` when transport unconfigured. Recipient hashed in log lines (DPDP §8)
`src/services/email-templates.ts` `welcomeEmail()` + `signupAttemptedNoticeEmail()`. Both return matched HTML + text. Pramaan + IN202311041001 footer. User-supplied fields HTML-escaped
`src/config/index.ts` New `config.email.*` exposing SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / EMAIL_FROM / EMAIL_FROM_NAME
`.env.example` Documented placeholders (no real creds in the repo — those live in `/opt/zeroauth/.env`)

F-2 partial mitigation (issue #27)

Closed half of the finding:

  • Timing oracle — duplicate-email signup path now runs scrypt to match the fresh-signup wall-clock time. The ~50ms timing gap is gone.
  • Security-signal email — duplicate-email signup now sends "someone tried to sign up with your email" to the legitimate account holder out-of-band. Email-was-taken is no longer free intel for an attacker — the legitimate user gets notified.

Still open:

The Medium severity drops to Low — there's still a status-code-based enumeration channel but it's slower, costs the attacker an email-delivery-rate-limit hit at Brevo per probe, and pings the legitimate user.

Test plan

  • `npx tsc --noEmit` — clean
  • `npm test` — 194 → 228 passing (+34 new)
  • `npm run lint` — 0 errors, 10 pre-existing warnings unchanged
  • `tests/email.test.ts` (24) — sendMail behavior, verifySmtp, template assertions including HTML-escape + no-credential-leak
  • `tests/console-signup.test.ts` (10) — fresh-signup 201 + welcome email queued, duplicate 409 + notice to legitimate holder + no tenant created + no leak in body + scrypt timing floor (>= 10ms)
  • CI green on this PR
  • After merge: production deployment requires the Brevo IP allowlist — see below

Production deploy pre-reqs (operator action)

Three steps. Without #1 every SMTP login fails:

  1. Brevo dashboard → Settings → SMTP & API → Authorized IPs → add `104.207.143.14` (the VPS public IP)
  2. VPS `/opt/zeroauth/.env` — add the six SMTP/EMAIL_ env vars (already in your local `.env`)
  3. Hostinger DNS — recommended for deliverability (without these, emails may land in spam):
    • SPF: `TXT @ "v=spf1 include:spf.brevo.com ~all"`
    • DKIM: `TXT mail._domainkey `
    • DMARC: `TXT _dmarc @ "v=DMARC1; p=quarantine; rua=mailto:dmarc@zeroauth.dev"`

If #1 isn't done by the time deploy fires, the service still runs — `sendMail()` just logs `5.7.1 Unauthorized IP` and returns `{ok:false, error}`. The signup endpoints still 201/409 normally; emails just don't get delivered.

Security note for the operator

The SMTP credentials were shared in chat. Rotate the key from the Brevo dashboard (Settings → SMTP & API → "Replace" the SMTP key) after the VPS deploy is confirmed working. Then update `/opt/zeroauth/.env` on the VPS with the new key. Chat transcripts are not a secret-handling channel.

🤖 Generated with Claude Code

ADR-0005 picks nodemailer v8 over emailjs / provider-specific SDKs
because it's the de-facto Node SMTP library (10M+ weekly DLs, MIT-0,
no critical CVEs in v8.x line) AND it keeps the provider choice in
config so Brevo → SES / Postmark / Resend is a swap, not a refactor.

What ships:

- adr/0005-adopt-nodemailer-for-smtp.md — full ADR per dep-add (DP6),
  with the four alternatives surveyed, operational pre-reqs spelled
  out (Brevo authorized IPs, SPF/DKIM/DMARC records), and threat
  model delta callout.
- src/services/email.ts — sendMail({to, subject, html, text}) wrapper,
  fire-and-forget contract (never throws), { ok:true, messageId } on
  success, { ok:false, error } on failure, { ok:false, skipped:true }
  when SMTP_HOST is empty (dev env). Recipient address hashed in log
  lines per DPDP §8 (we don't ship end-user emails to log aggregators).
- src/services/email-templates.ts — welcomeEmail + signupAttemptedNoticeEmail.
  Both return { subject, html, text }; html and text carry the same
  information; both have Pramaan + IN202311041001 footer; user-supplied
  fields HTML-escaped. Forbidden content (API keys, password values,
  biometric-derived data) absent and asserted in tests.
- src/config/index.ts — adds config.email with SMTP_HOST/SMTP_PORT/
  SMTP_USER/SMTP_PASSWORD/EMAIL_FROM/EMAIL_FROM_NAME env vars.
- .env.example — adds documented placeholders for all six. Real
  credentials live in /opt/zeroauth/.env on the VPS only.

F-2 partial mitigation (issue #27):

- /api/console/signup duplicate-email path now (a) runs scrypt to
  equalize timing with the fresh-signup path (closes the timing-oracle
  half of the leak), (b) sends a "someone tried to sign up with your
  email" notice to the legitimate account holder out-of-band.
- /api/console/signup fresh path now sends a welcome email after the
  201 response (out-of-band, fire-and-forget — never blocks the
  request). Email NEVER contains the API key (the existing dashboard
  one-time-reveal stays the only key-exposure surface, per
  security-policy.md §10).
- The 201/409 status-code split STAYS for now because the byte-
  identical fix (return 202 always + email-verification flow) is a
  breaking change to the dashboard signup-then-reveal-key flow + the
  Playwright happy path; that's the v2 mitigation, deferred to a
  follow-up PR.

Tests: 194 → 228 (+34 new):

- tests/email.test.ts (24) — sendMail unconfigured no-op,
  configured envelope shape, replyTo handling, error returns,
  verifySmtp success/failure, welcomeEmail + signupAttemptedNoticeEmail
  shape + HTML-escape + Pramaan footer + no credentials-leak assertions
- tests/console-signup.test.ts (10) — fresh signup 201 + welcome
  email queued + no API key in body, duplicate signup 409 + notice
  email to legitimate holder + no tenant created + no tenant_id leak
  in 409 body + scrypt-bound minimum response time (>= 10ms),
  invalid-input 400s that skip both DB lookup and email send

Lint: 0 errors. Typecheck clean.

Production deploy pre-requisites:

1. Add 104.207.143.14 to Brevo Settings → SMTP & API → Authorized IPs
2. Add SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / EMAIL_FROM /
   EMAIL_FROM_NAME to /opt/zeroauth/.env on the VPS
3. (Optional but recommended) Add SPF + DKIM + DMARC records on
   zeroauth.dev via Hostinger:
   - SPF:   TXT @  "v=spf1 include:spf.brevo.com ~all"
   - DKIM:  TXT mail._domainkey  <value from Brevo dashboard>
   - DMARC: TXT _dmarc @  "v=DMARC1; p=quarantine; rua=mailto:dmarc@zeroauth.dev"

Without (1), every SMTP login returns 5.7.1 Unauthorized IP and
sendMail() returns { ok:false, error: 'Unauthorized IP' } — the
service still runs, just doesn't deliver. Without (3), Brevo-sent
mail may land in spam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 14, 2026 11:40
@pulkitpareek18 pulkitpareek18 merged commit 8a85e33 into main May 14, 2026
3 of 4 checks passed
@pulkitpareek18 pulkitpareek18 deleted the dev branch May 14, 2026 11:41
@pulkitpareek18 pulkitpareek18 review requested due to automatic review settings May 14, 2026 12:05
pulkitpareek18 added a commit that referenced this pull request May 15, 2026
ADR-0005 picks nodemailer v8 over emailjs / provider-specific SDKs
because it's the de-facto Node SMTP library (10M+ weekly DLs, MIT-0,
no critical CVEs in v8.x line) AND it keeps the provider choice in
config so Brevo → SES / Postmark / Resend is a swap, not a refactor.

What ships:

- adr/0005-adopt-nodemailer-for-smtp.md — full ADR per dep-add (DP6),
  with the four alternatives surveyed, operational pre-reqs spelled
  out (Brevo authorized IPs, SPF/DKIM/DMARC records), and threat
  model delta callout.
- src/services/email.ts — sendMail({to, subject, html, text}) wrapper,
  fire-and-forget contract (never throws), { ok:true, messageId } on
  success, { ok:false, error } on failure, { ok:false, skipped:true }
  when SMTP_HOST is empty (dev env). Recipient address hashed in log
  lines per DPDP §8 (we don't ship end-user emails to log aggregators).
- src/services/email-templates.ts — welcomeEmail + signupAttemptedNoticeEmail.
  Both return { subject, html, text }; html and text carry the same
  information; both have Pramaan + IN202311041001 footer; user-supplied
  fields HTML-escaped. Forbidden content (API keys, password values,
  biometric-derived data) absent and asserted in tests.
- src/config/index.ts — adds config.email with SMTP_HOST/SMTP_PORT/
  SMTP_USER/SMTP_PASSWORD/EMAIL_FROM/EMAIL_FROM_NAME env vars.
- .env.example — adds documented placeholders for all six. Real
  credentials live in /opt/zeroauth/.env on the VPS only.

F-2 partial mitigation (issue #27):

- /api/console/signup duplicate-email path now (a) runs scrypt to
  equalize timing with the fresh-signup path (closes the timing-oracle
  half of the leak), (b) sends a "someone tried to sign up with your
  email" notice to the legitimate account holder out-of-band.
- /api/console/signup fresh path now sends a welcome email after the
  201 response (out-of-band, fire-and-forget — never blocks the
  request). Email NEVER contains the API key (the existing dashboard
  one-time-reveal stays the only key-exposure surface, per
  security-policy.md §10).
- The 201/409 status-code split STAYS for now because the byte-
  identical fix (return 202 always + email-verification flow) is a
  breaking change to the dashboard signup-then-reveal-key flow + the
  Playwright happy path; that's the v2 mitigation, deferred to a
  follow-up PR.

Tests: 194 → 228 (+34 new):

- tests/email.test.ts (24) — sendMail unconfigured no-op,
  configured envelope shape, replyTo handling, error returns,
  verifySmtp success/failure, welcomeEmail + signupAttemptedNoticeEmail
  shape + HTML-escape + Pramaan footer + no credentials-leak assertions
- tests/console-signup.test.ts (10) — fresh signup 201 + welcome
  email queued + no API key in body, duplicate signup 409 + notice
  email to legitimate holder + no tenant created + no tenant_id leak
  in 409 body + scrypt-bound minimum response time (>= 10ms),
  invalid-input 400s that skip both DB lookup and email send

Lint: 0 errors. Typecheck clean.

Production deploy pre-requisites:

1. Add 104.207.143.14 to Brevo Settings → SMTP & API → Authorized IPs
2. Add SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / EMAIL_FROM /
   EMAIL_FROM_NAME to /opt/zeroauth/.env on the VPS
3. (Optional but recommended) Add SPF + DKIM + DMARC records on
   zeroauth.dev via Hostinger:
   - SPF:   TXT @  "v=spf1 include:spf.brevo.com ~all"
   - DKIM:  TXT mail._domainkey  <value from Brevo dashboard>
   - DMARC: TXT _dmarc @  "v=DMARC1; p=quarantine; rua=mailto:dmarc@zeroauth.dev"

Without (1), every SMTP login returns 5.7.1 Unauthorized IP and
sendMail() returns { ok:false, error: 'Unauthorized IP' } — the
service still runs, just doesn't deliver. Without (3), Brevo-sent
mail may land in spam.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant