Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,16 @@ POSTGRES_PORT=5432
POSTGRES_DB=zeroauth
POSTGRES_USER=zeroauth
POSTGRES_PASSWORD=zeroauth-dev

# ── Email (Brevo SMTP) ──────────────────────────────
# Per ADR-0005. Required for signup welcome emails, password reset,
# and the DPDP §8(7) breach-notification procedure.
# Brevo dashboard → Settings → SMTP & API → Authorized IPs must list
# the sending IP (104.207.143.14 for the prod VPS) or every login
# returns 5.7.1 Unauthorized IP.
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_USER=<your-brevo-smtp-login>@smtp-brevo.com
SMTP_PASSWORD=<your-brevo-smtp-key>
EMAIL_FROM=noreply@zeroauth.dev
EMAIL_FROM_NAME=ZeroAuth
80 changes: 80 additions & 0 deletions adr/0005-adopt-nodemailer-for-smtp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# ADR-0005 — Adopt `nodemailer` for transactional SMTP

## Status

Accepted

## Context

[Issue #27](https://github.com/pulkitpareek18/ZeroAuth/issues/27) (F-2 from PR #22 security review) needs email infrastructure to close the email-enumeration finding properly. Beyond that single fix, several pending workstreams converge on "we need transactional email":

- **Breach-notification procedure** in `pulkitpareek18/ZeroAuth-Governance: docs/shared/breach-notification.md` step §3 requires emailing every affected tenant within 6 hours of confirmation — currently has no implementation
- **Password reset flow** — entirely missing today; we ship console accounts with no recovery path
- **Welcome email on signup** — minor UX win, plus a server-side signal that the address is real
- **"Someone tried to sign up with your email" notice** — security signal for legitimate account holders, partial mitigation for F-2 enumeration
- **Pilot SOW workflows** — future need; sending NDAs / DPAs / evidence packs

The user provided Brevo SMTP credentials on 2026-05-14, unblocking this work. We need a Node SMTP client.

## Decision

Adopt **`nodemailer` v8.x** (latest stable, MIT-0 licensed) as the SMTP transport library for the API repo. Wrap it behind `src/services/email.ts` so the rest of the codebase imports a generic `sendMail(opts)` function, not nodemailer directly. This keeps the option open to swap to Postmark / Resend / SES later without touching call sites.

## Consequences

- **Positive — battle-tested SMTP.** Nodemailer has been the de-facto Node SMTP library since 2010. 4M+ weekly downloads. No known critical CVEs in the v8.x line. The API is stable across major versions.
- **Positive — provider-agnostic.** Brevo (today) → SES / Postmark / Mailgun (future) is a config change, not a code change. No SDK lock-in.
- **Positive — TLS + DKIM signing support.** Nodemailer handles `STARTTLS` on port 587 (what Brevo uses) and supports per-message DKIM signing if we want it later.
- **Negative — Bayesian transitive surface.** Nodemailer pulls a small graph (mostly its own author's packages: `nodemailer-shared`, etc.). Acceptable for a 4M-DL library.
- **Negative — SMTP not HTTPS.** SMTP authentication via plaintext credentials over STARTTLS works but lacks the per-request auth tokens that an HTTP API like SES / Postmark / Resend provide. Mitigated by SMTP creds living in `/opt/zeroauth/.env` only (never in code) and being rotatable on the Brevo dashboard.
- **Neutral — zero existing email infra.** This is the first email-sending dep; no replacement.

## Alternatives considered

- **`emailjs` (`emailjs` package, v5.x)** — alternative SMTP client. Smaller user base, smaller community. Less defensive against TLS edge cases. Rejected because nodemailer is the industry standard and our blast radius from picking the niche library isn't worth the marginal dep-tree saving.
- **Postmark SDK (`postmark` package)** — provider-specific HTTP API, very developer-friendly. **Rejected for now** because (a) the user picked Brevo, not Postmark, (b) we want provider-agnosticism for future swaps, (c) the SDK adds provider lock-in for a function we can wrap in 30 lines of nodemailer.
- **`@sendgrid/mail`** — SendGrid SDK. Same rejection reasoning as Postmark.
- **AWS SES SDK (`@aws-sdk/client-sesv2`)** — heavy AWS SDK transitively. Cheaper send cost in volume but requires AWS account + IAM setup. Provider-specific. **Deferred** — could be the next provider swap when we outgrow Brevo's free tier (300 sends/day).
- **Roll our own SMTP via `net` / `tls`** — no.

## Configuration

- Provider: **Brevo** (formerly SendInBlue)
- SMTP host: `smtp-relay.brevo.com`
- Port: `587` (STARTTLS)
- From address: `noreply@zeroauth.dev`
- Credentials: live in `/opt/zeroauth/.env` on the VPS; in `.env` locally (gitignored). `.env.example` documents the variable names without real values.

**Operational pre-requisites that must be satisfied before this works in production:**

1. Brevo dashboard → Settings → SMTP & API → Authorized IPs → add `104.207.143.14` (VPS public IP). Without this, every SMTP login fails with `5.7.1 Unauthorized IP address`.
2. DNS records on `zeroauth.dev` (Hostinger panel):
- **SPF** `TXT @ "v=spf1 include:spf.brevo.com ~all"`
- **DKIM** `TXT mail._domainkey` — value provided by Brevo dashboard
- **DMARC** `TXT _dmarc @ "v=DMARC1; p=quarantine; rua=mailto:dmarc@zeroauth.dev"`
Without these, Brevo-sent mail lands in spam or gets rejected outright by recipient servers.
3. Brevo account quota: free tier = 300 emails/day. Pilot phase is well under that; revisit when public traffic ramps.

## Threat model delta

- New egress to `smtp-relay.brevo.com:587` from the API process. Update `pulkitpareek18/ZeroAuth-Governance: docs/threat-model/canonical.md` to add A-V06 (SMTP credential exfiltration / Brevo account takeover risk) — tracked as a follow-up.

## Operational notes

- The `email` service exposes a single function: `sendMail({ to, subject, html, text }): Promise<{ messageId, accepted }>`. Errors are logged + swallowed (not thrown to callers) for fire-and-forget transactional emails.
- For mission-critical mail (breach notification per `breach-notification.md`), a separate `sendCritical()` function is on the roadmap that retries 3x with exponential backoff and alerts on final failure.
- Email templates live in `src/services/email-templates/` as functions that return `{ subject, html, text }`. Plain-string templates initially; can move to mjml / handlebars when complexity warrants.

## References

- nodemailer package: <https://www.npmjs.com/package/nodemailer>
- nodemailer source: <https://github.com/nodemailer/nodemailer>
- nodemailer license (MIT-0): <https://github.com/nodemailer/nodemailer/blob/master/LICENSE>
- Brevo SMTP docs: <https://developers.brevo.com/docs/smtp-integration>
- DPDP §8(7) breach-notification procedure that depends on this: `pulkitpareek18/ZeroAuth-Governance: docs/shared/breach-notification.md`
- Issue this unblocks: <https://github.com/pulkitpareek18/ZeroAuth/issues/27>

---

LAST_UPDATED: 2026-05-14
OWNER: Pulkit Pareek
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"helmet": "^8.1.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^8.0.7",
"pg": "^8.20.0",
"snarkjs": "^0.7.6",
"uuid": "^9.0.0",
Expand All @@ -57,6 +58,7 @@
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.10.6",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@types/snarkjs": "^0.7.9",
"@types/supertest": "^6.0.2",
Expand Down
13 changes: 13 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,17 @@ export const config = {
user: process.env.POSTGRES_USER ?? 'zeroauth',
password: requireEnv('POSTGRES_PASSWORD', 'zeroauth-dev'),
},

// ADR-0005 — transactional SMTP via nodemailer + Brevo.
// All five env vars must be set in production for emails to actually
// send; when SMTP_HOST is empty src/services/email.ts no-ops with a
// warn log instead of failing requests.
email: {
smtpHost: process.env.SMTP_HOST ?? '',
smtpPort: parseInt(process.env.SMTP_PORT ?? '587', 10),
smtpUser: process.env.SMTP_USER ?? '',
smtpPassword: process.env.SMTP_PASSWORD ?? '',
fromAddress: process.env.EMAIL_FROM ?? 'noreply@zeroauth.dev',
fromName: process.env.EMAIL_FROM_NAME ?? 'ZeroAuth',
},
} as const;
63 changes: 55 additions & 8 deletions src/routes/console.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { randomUUID } from 'crypto';
import { randomUUID, scrypt as _scrypt } from 'crypto';
import { promisify } from 'util';
import rateLimit from 'express-rate-limit';

const scrypt = promisify(_scrypt) as (
password: string,
salt: string,
keylen: number,
) => Promise<Buffer>;
import { config } from '../config';
import { logger } from '../services/logger';
import { createTenant, authenticateTenant, getTenantById, getTenantByEmail } from '../services/tenants';
Expand Down Expand Up @@ -30,6 +37,8 @@
VerificationMethod,
VerificationResult,
} from '../types';
import { sendMail } from '../services/email';
import { welcomeEmail, signupAttemptedNoticeEmail } from '../services/email-templates';

const router = Router();

Expand Down Expand Up @@ -172,15 +181,42 @@
return;
}

// F-2 (issue #26): email-enumeration via the 201/409 status-code split
// is a real Medium finding, but the **byte-identical** fix requires
// email infrastructure (return 202 always, send verification link
// out-of-band). We don't have email infra yet — that's tracked as a
// separate ADR. Until then we keep 409 and accept the leak; the
// interim mitigation (uniform 400) was rejected because it ALSO leaks
// (existing → 400, fresh → 201). See issue #26 F-2 — left open.
// F-2 (issue #27): email-enumeration mitigation — partial.
//
// The full byte-identical fix (return 202 always + verification email)
// requires a tenant-creation rework that breaks the existing dashboard
// signup-then-reveal-key flow + the Playwright happy path. That work
// is tracked in issue #27 as the v2 of this mitigation.
//
// For now: keep the 201/409 split (so dashboard signup still works in
// one round-trip) but plug the worst-leak surfaces:
// 1. Timing equalization — when the email exists, do the scrypt
// hashing the createTenant() path would have done. Hashing
// dominates the response time, so without this the 409 path
// is observably ~50ms faster than the 201 path. With this,
// both paths take the same wall-clock time.
// 2. Security-signal email — send a "someone tried to sign up
// with your email" notice to the legitimate account holder,
// so the email-was-taken response isn't free intel for an
// attacker probing addresses.
//
// See ADR-0005 (email service), governance/docs/threat-model/api.md A-05.
const existing = await getTenantByEmail(email);
if (existing) {
// Burn the same CPU we'd burn for createTenant() so the timing
// oracle is closed. scrypt is the dominant cost; do it explicitly.
try {
await scrypt(password, 'enumeration-mitigation-salt', 64);
} catch { /* swallow */ }

// Notify the legitimate operator out-of-band. Fire-and-forget;
// never block the response.
const sourceIp = (req.ip || req.headers['x-forwarded-for'] || '').toString().slice(0, 64) || null;
void (async () => {
const tmpl = signupAttemptedNoticeEmail({ email: existing.email, attemptIp: sourceIp });
await sendMail({ to: existing.email, ...tmpl });
})();

res.status(409).json({ error: 'email_taken', message: 'An account with this email already exists.' });
return;
}
Expand All @@ -204,6 +240,17 @@
metadata: { companyName: tenant.company_name, plan: tenant.plan },
}).catch(() => undefined);

// Send welcome email out-of-band — never block the signup response.
// We deliberately do NOT email the API key (per security-policy §10).
void (async () => {
const tmpl = welcomeEmail({
email: tenant.email,
companyName: tenant.company_name ?? null,
tenantId: tenant.id,
});
await sendMail({ to: tenant.email, ...tmpl });
})();

res.status(201).json({
message: 'Account created successfully.',
token,
Expand Down Expand Up @@ -290,7 +337,7 @@
const { tenantId } = (req as any).console;
const keys = await listApiKeys(tenantId);
res.json({ keys });
} catch (err) {

Check warning on line 340 in src/routes/console.ts

View workflow job for this annotation

GitHub Actions / validate

'err' is defined but never used. Allowed unused caught errors must match /^_/u

Check warning on line 340 in src/routes/console.ts

View workflow job for this annotation

GitHub Actions / validate

'err' is defined but never used. Allowed unused caught errors must match /^_/u
res.status(500).json({ error: 'Failed to list keys.' });
}
});
Expand Down Expand Up @@ -354,7 +401,7 @@
}

res.json({ message: 'API key revoked successfully.', keyId });
} catch (err) {

Check warning on line 404 in src/routes/console.ts

View workflow job for this annotation

GitHub Actions / validate

'err' is defined but never used. Allowed unused caught errors must match /^_/u

Check warning on line 404 in src/routes/console.ts

View workflow job for this annotation

GitHub Actions / validate

'err' is defined but never used. Allowed unused caught errors must match /^_/u
res.status(500).json({ error: 'Failed to revoke key.' });
}
});
Expand Down Expand Up @@ -392,7 +439,7 @@
history,
recentCalls,
});
} catch (err) {

Check warning on line 442 in src/routes/console.ts

View workflow job for this annotation

GitHub Actions / validate

'err' is defined but never used. Allowed unused caught errors must match /^_/u

Check warning on line 442 in src/routes/console.ts

View workflow job for this annotation

GitHub Actions / validate

'err' is defined but never used. Allowed unused caught errors must match /^_/u
res.status(500).json({ error: 'Failed to fetch usage.' });
}
});
Expand Down Expand Up @@ -421,7 +468,7 @@
monthlyQuota: tenant.monthly_quota,
createdAt: tenant.created_at,
});
} catch (err) {

Check warning on line 471 in src/routes/console.ts

View workflow job for this annotation

GitHub Actions / validate

'err' is defined but never used. Allowed unused caught errors must match /^_/u

Check warning on line 471 in src/routes/console.ts

View workflow job for this annotation

GitHub Actions / validate

'err' is defined but never used. Allowed unused caught errors must match /^_/u
res.status(500).json({ error: 'Failed to fetch account.' });
}
});
Expand All @@ -437,7 +484,7 @@
const environment = (req.query.environment === 'test' ? 'test' : 'live') as ApiKeyEnvironment;
const overview = await getConsoleOverview(tenantId, environment);
res.json(overview);
} catch (err) {

Check warning on line 487 in src/routes/console.ts

View workflow job for this annotation

GitHub Actions / validate

'err' is defined but never used. Allowed unused caught errors must match /^_/u

Check warning on line 487 in src/routes/console.ts

View workflow job for this annotation

GitHub Actions / validate

'err' is defined but never used. Allowed unused caught errors must match /^_/u
res.status(500).json({ error: 'Failed to fetch overview.' });
}
});
Expand Down
Loading
Loading