diff --git a/.env.example b/.env.example index c9925f9..d3638e3 100644 --- a/.env.example +++ b/.env.example @@ -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=@smtp-brevo.com +SMTP_PASSWORD= +EMAIL_FROM=noreply@zeroauth.dev +EMAIL_FROM_NAME=ZeroAuth diff --git a/adr/0005-adopt-nodemailer-for-smtp.md b/adr/0005-adopt-nodemailer-for-smtp.md new file mode 100644 index 0000000..002264e --- /dev/null +++ b/adr/0005-adopt-nodemailer-for-smtp.md @@ -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: +- nodemailer source: +- nodemailer license (MIT-0): +- Brevo SMTP docs: +- DPDP §8(7) breach-notification procedure that depends on this: `pulkitpareek18/ZeroAuth-Governance: docs/shared/breach-notification.md` +- Issue this unblocks: + +--- + +LAST_UPDATED: 2026-05-14 +OWNER: Pulkit Pareek diff --git a/package-lock.json b/package-lock.json index 9ff6f01..0b41b01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,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", @@ -37,6 +38,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", @@ -3699,6 +3701,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pbkdf2": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@types/pbkdf2/-/pbkdf2-3.1.2.tgz", @@ -10563,6 +10575,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nofilter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", diff --git a/package.json b/package.json index fa4508b..8cae483 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/config/index.ts b/src/config/index.ts index 46d2718..2a0e65f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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; diff --git a/src/routes/console.ts b/src/routes/console.ts index 3bf1879..f8ca57e 100644 --- a/src/routes/console.ts +++ b/src/routes/console.ts @@ -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; import { config } from '../config'; import { logger } from '../services/logger'; import { createTenant, authenticateTenant, getTenantById, getTenantByEmail } from '../services/tenants'; @@ -30,6 +37,8 @@ import { VerificationMethod, VerificationResult, } from '../types'; +import { sendMail } from '../services/email'; +import { welcomeEmail, signupAttemptedNoticeEmail } from '../services/email-templates'; const router = Router(); @@ -172,15 +181,42 @@ router.post('/signup', authLimiter, async (req: Request, res: Response) => { 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; } @@ -204,6 +240,17 @@ router.post('/signup', authLimiter, async (req: Request, res: Response) => { 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, diff --git a/src/services/email-templates.ts b/src/services/email-templates.ts new file mode 100644 index 0000000..d27562b --- /dev/null +++ b/src/services/email-templates.ts @@ -0,0 +1,151 @@ +/** + * Email body templates. + * + * Plain-string templates today; can move to mjml / handlebars / a real + * template engine when complexity warrants. Each export returns + * `{ subject, html, text }` — html is for clients that render it, text + * is the fallback. Both must contain the same information. + * + * Per `governance: docs/shared/security-policy.md` §10, **never** include: + * - API keys (plaintext) in any email + * - Password hashes + * - Biometric-derived data + * - Cross-tenant identifiers + * + * Per DPDP §8, the email content is logged at the message-id level but the + * recipient address is hashed (see services/email.ts:hashRecipient). + */ + +const FOOTER_HTML = ` +
+

+ Sent by ZeroAuth, the developer-facing API for Pramaan™ — the + patented zero-knowledge biometric identity protocol.
+ Yushu Excellence Technologies Pvt. Ltd. · Indian Patent + IN202311041001.
+ Questions? Reply to this email or open an issue at + github.com/pulkitpareek18/ZeroAuth/issues. +

+`; + +const FOOTER_TEXT = ` +--- +Sent by ZeroAuth, the developer-facing API for Pramaan(TM) — the patented +zero-knowledge biometric identity protocol. Yushu Excellence Technologies +Pvt. Ltd. — Indian Patent IN202311041001. +Questions? Reply to this email or open an issue at +https://github.com/pulkitpareek18/ZeroAuth/issues +`; + +/** + * Sent immediately after a successful tenant signup. Confirms the account + * exists, gives the operator a Quickstart pointer, and reminds them their + * first API key was already revealed in the dashboard (not in this email — + * we never email plaintext keys). + */ +export function welcomeEmail(input: { + email: string; + companyName: string | null; + tenantId: string; +}): { subject: string; html: string; text: string } { + const companyOrAccount = input.companyName?.trim() || 'your account'; + const subject = `Welcome to ZeroAuth — ${companyOrAccount} is live`; + + const html = ` +
+

Welcome to ZeroAuth.

+

+ Your developer account ${input.email} is active${input.companyName ? ` and linked to ${escapeHtml(input.companyName)}` : ''}. +

+

+ Your first API key was revealed once in the dashboard — copy it to your password manager + if you haven't yet. We never email plaintext API keys, by design (per our security policy). + If you lost it, mint a new one from the API Keys page. +

+

+ Next steps: +

+
    +
  • Read the Quickstart
  • +
  • Verify your first proof: curl https://zeroauth.dev/v1/auth/zkp/verify
  • +
  • Skim the Pramaan whitepaper (25 pages, technical)
  • +
+ ${FOOTER_HTML} +
+ `; + + const text = `Welcome to ZeroAuth. + +Your developer account ${input.email} is active${input.companyName ? ` and linked to ${input.companyName}` : ''}. + +Your first API key was revealed once in the dashboard — copy it to your password manager if you haven't yet. We never email plaintext API keys, by design (per our security policy). If you lost it, mint a new one from https://zeroauth.dev/dashboard/api-keys + +Next steps: +- Read the Quickstart: https://zeroauth.dev/docs/getting-started/quickstart/ +- Verify your first proof: curl https://zeroauth.dev/v1/auth/zkp/verify +- Skim the Pramaan whitepaper: https://zeroauth.dev/docs/whitepaper.pdf +${FOOTER_TEXT}`; + + return { subject, html, text }; +} + +/** + * Sent to a legitimate account holder when a signup is attempted on + * their already-registered email. Partial mitigation for F-2 (the + * enumeration finding) — gives the real user a security signal AND + * prevents the email-was-taken response from being free intel for + * an attacker. + */ +export function signupAttemptedNoticeEmail(input: { + email: string; + attemptIp: string | null; +}): { subject: string; html: string; text: string } { + const subject = `Someone tried to sign up with your ZeroAuth email`; + + const html = ` +
+

Heads up.

+

+ Someone just tried to create a new ZeroAuth account with ${input.email}. + Your account already exists, so the signup was rejected. +

+

+ If this was you (you forgot you had an account), sign in at + zeroauth.dev/dashboard/login. +

+

+ If this wasn't you, your account is unaffected — no password attempt was made. + Consider rotating your password as a precaution: + dashboard/login → forgot password. +

+ ${input.attemptIp ? `

Attempt source IP: ${escapeHtml(input.attemptIp)}

` : ''} + ${FOOTER_HTML} +
+ `; + + const text = `Heads up. + +Someone just tried to create a new ZeroAuth account with ${input.email}. +Your account already exists, so the signup was rejected. + +If this was you (you forgot you had an account), sign in at https://zeroauth.dev/dashboard/login + +If this wasn't you, your account is unaffected — no password attempt was made. Consider rotating your password as a precaution: dashboard/login → forgot password. +${input.attemptIp ? `\nAttempt source IP: ${input.attemptIp}\n` : ''}${FOOTER_TEXT}`; + + return { subject, html, text }; +} + +/** + * Minimal HTML escape for user-supplied strings landing in templates. + * Don't use a full library for this — the surface is tiny (operator email + * + company name) and a 4-line escape is cheaper to audit. + */ +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/services/email.ts b/src/services/email.ts new file mode 100644 index 0000000..8c780df --- /dev/null +++ b/src/services/email.ts @@ -0,0 +1,152 @@ +import nodemailer, { Transporter } from 'nodemailer'; +import { createHash } from 'crypto'; +import { config } from '../config'; +import { logger } from './logger'; + +/** + * Transactional email service (ADR-0005). + * + * Wraps nodemailer behind a generic interface so call sites don't depend + * on the transport choice. Today: SMTP via Brevo (smtp-relay.brevo.com:587). + * Tomorrow: could swap to Postmark / Resend / SES by replacing the + * `createTransport` body. + * + * Module-level singleton. Built at startup if SMTP_HOST is configured; + * if not, `sendMail` no-ops with a warn log instead of failing requests. + * That's the right shape for transactional email — never block a user + * flow on a transient SMTP outage. + * + * For mission-critical mail (breach notification per + * `governance: docs/shared/breach-notification.md`), use `sendCriticalMail` + * instead — it retries 3x with exponential backoff and alerts on final + * failure. Not yet implemented; tracked. + */ + +let transporter: Transporter | null = null; + +function getTransporter(): Transporter | null { + if (transporter) return transporter; + if (!config.email.smtpHost || !config.email.smtpUser || !config.email.smtpPassword) { + return null; + } + transporter = nodemailer.createTransport({ + host: config.email.smtpHost, + port: config.email.smtpPort, + secure: false, // STARTTLS upgrade on port 587 + auth: { + user: config.email.smtpUser, + pass: config.email.smtpPassword, + }, + }); + return transporter; +} + +export interface SendMailInput { + to: string; + subject: string; + html: string; + /** Plain-text fallback. If omitted, recipients with HTML-disabled clients see nothing useful. */ + text: string; + /** Optional Reply-To override. Defaults to EMAIL_FROM. */ + replyTo?: string; +} + +export interface SendMailResult { + ok: boolean; + messageId?: string; + error?: string; + /** True when the transport is unconfigured (dev env, no SMTP_HOST) — call was a no-op. */ + skipped?: boolean; +} + +/** + * Send a transactional email. Fire-and-forget at call sites — never throws. + * Logs success / failure but returns a structured result for callers that + * want to act on it (e.g. surface a "we couldn't email you" banner). + * + * **Never** include the recipient's email in log lines unless the recipient + * is the tenant operator (an internal email is fine to log; an end-user + * email is not — DPDP §8 considers it personal data). + */ +export async function sendMail(input: SendMailInput): Promise { + const t = getTransporter(); + if (!t) { + logger.warn('Email: SMTP not configured — sendMail() is a no-op', { + to: hashRecipient(input.to), + subject: input.subject, + }); + return { ok: false, skipped: true }; + } + + try { + const info = await t.sendMail({ + from: `"${config.email.fromName}" <${config.email.fromAddress}>`, + to: input.to, + replyTo: input.replyTo ?? config.email.fromAddress, + subject: input.subject, + text: input.text, + html: input.html, + }); + logger.info('Email: sent', { + messageId: info.messageId, + to: hashRecipient(input.to), + subject: input.subject, + }); + return { ok: true, messageId: info.messageId }; + } catch (err) { + const error = (err as Error).message; + logger.error('Email: send failed', { + to: hashRecipient(input.to), + subject: input.subject, + error, + }); + return { ok: false, error }; + } +} + +/** + * Hash the recipient address before logging so the log stream is safe to + * ship to an external aggregator without leaking PII per DPDP §8. + * + * Uses a length-truncated SHA-256 of the lowercased trimmed address. + * Reversible by lookup but not by inference. Same input always produces + * the same hash, which lets us correlate failures for a single recipient + * without storing the email itself. + */ +function hashRecipient(to: string): string { + return createHash('sha256') + .update(to.trim().toLowerCase()) + .digest('hex') + .slice(0, 12); +} + +/** + * Probe the SMTP connection. Used by the startup health check. + * Returns true if the transport accepts a NOOP, false otherwise. + */ +export async function verifySmtp(): Promise { + const t = getTransporter(); + if (!t) return false; + try { + await t.verify(); + logger.info('Email: SMTP transport verified', { + host: config.email.smtpHost, + port: config.email.smtpPort, + }); + return true; + } catch (err) { + logger.warn('Email: SMTP verify failed', { + host: config.email.smtpHost, + error: (err as Error).message, + }); + return false; + } +} + +/** + * Reset the transporter — only intended for tests. Production code never + * calls this; the module-level singleton is the deliberate shape. + */ +export function _resetTransporterForTests(): void { + transporter = null; +} diff --git a/tests/console-signup.test.ts b/tests/console-signup.test.ts new file mode 100644 index 0000000..5ba7614 --- /dev/null +++ b/tests/console-signup.test.ts @@ -0,0 +1,226 @@ +/** + * Integration tests for the F-2 partial mitigation in /api/console/signup + * (issue #27). Asserts: + * + * - Fresh signup → 201 with token + apiKey + welcome email queued + * - Duplicate signup → 409 email_taken + notice email queued + no leak + * of credentials in the 409 response body + * - The 409 path runs scrypt (timing equalization), so the wall-clock + * time of the two paths is similar (not byte-identical, but no longer + * a free timing oracle) + * - The welcome email goes to the new tenant's email + * - The notice email goes to the EXISTING tenant's email (NOT the + * attacker's email — that's the whole point of the notice) + * + * The full byte-identical F-2 fix (return 202 always + email verification + * link to complete signup) is the v2, deferred to a follow-up PR because + * it breaks the existing dashboard signup flow + Playwright happy path. + */ + +const sendMailMock = jest.fn(); +const createTenantMock = jest.fn(); +const authenticateTenantMock = jest.fn(); +const getTenantByIdMock = jest.fn(); +const getTenantByEmailMock = jest.fn(); +const createApiKeyMock = jest.fn(); + +jest.mock('../src/services/email', () => ({ + sendMail: (...args: unknown[]) => sendMailMock(...args), + _resetTransporterForTests: jest.fn(), +})); + +jest.mock('../src/services/tenants', () => ({ + createTenant: (...args: unknown[]) => createTenantMock(...args), + authenticateTenant: (...args: unknown[]) => authenticateTenantMock(...args), + getTenantById: (...args: unknown[]) => getTenantByIdMock(...args), + getTenantByEmail: (...args: unknown[]) => getTenantByEmailMock(...args), +})); + +jest.mock('../src/services/api-keys', () => ({ + createApiKey: (...args: unknown[]) => createApiKeyMock(...args), + listApiKeys: jest.fn().mockResolvedValue([]), + revokeApiKey: jest.fn(), + countActiveKeys: jest.fn().mockResolvedValue(0), +})); + +jest.mock('../src/services/platform', () => ({ + recordAuditEvent: jest.fn().mockResolvedValue(undefined), + getConsoleOverview: jest.fn(), + listAuditEvents: jest.fn(), + createDevice: jest.fn(), + listDevices: jest.fn(), + updateDevice: jest.fn(), + createTenantUser: jest.fn(), + listTenantUsers: jest.fn(), + updateTenantUser: jest.fn(), + listVerificationEvents: jest.fn(), + listAttendanceEvents: jest.fn(), +})); + +jest.mock('../src/services/usage', () => ({ + getUsageSummary: jest.fn(), + getRecentCalls: jest.fn(), + getCurrentMonthUsage: jest.fn().mockResolvedValue(0), +})); + +import request from 'supertest'; +import { createApp } from '../src/app'; + +const app = createApp(); + +const VALID_PASSWORD = 'Aa1!stuvwxyz'; + +describe('POST /api/console/signup — F-2 partial mitigation (issue #27)', () => { + beforeEach(() => { + sendMailMock.mockReset(); + createTenantMock.mockReset(); + getTenantByEmailMock.mockReset(); + createApiKeyMock.mockReset(); + sendMailMock.mockResolvedValue({ ok: true, messageId: '' }); + }); + + describe('fresh email signup', () => { + beforeEach(() => { + getTenantByEmailMock.mockResolvedValue(null); + createTenantMock.mockResolvedValue({ + id: 'tenant-new', + email: 'fresh@example.com', + company_name: 'Acme', + plan: 'free', + status: 'active', + }); + createApiKeyMock.mockResolvedValue({ + key: 'za_live_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + id: 'key-1', + name: 'Default Live Key', + key_prefix: 'za_live_aaaaaa', + scopes: [], + environment: 'live', + }); + }); + + it('returns 201 with the token + apiKey shape', async () => { + const res = await request(app) + .post('/api/console/signup') + .send({ email: 'fresh@example.com', password: VALID_PASSWORD, companyName: 'Acme' }); + + expect(res.status).toBe(201); + expect(res.body.token).toBeDefined(); + expect(res.body.apiKey.key).toMatch(/^za_live_[a-f0-9]{48}$/); + expect(res.body.tenant.id).toBe('tenant-new'); + }); + + it('triggers the welcome email to the new tenant\'s address', async () => { + await request(app) + .post('/api/console/signup') + .send({ email: 'fresh@example.com', password: VALID_PASSWORD, companyName: 'Acme' }); + + // Welcome email is fire-and-forget — wait one tick for the void IIFE. + await new Promise(resolve => setImmediate(resolve)); + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'fresh@example.com', + subject: expect.stringContaining('Welcome to ZeroAuth'), + }), + ); + }); + + it('welcome email body never contains the API key (security-policy §10)', async () => { + await request(app) + .post('/api/console/signup') + .send({ email: 'fresh@example.com', password: VALID_PASSWORD, companyName: 'Acme' }); + + await new Promise(resolve => setImmediate(resolve)); + + const call = sendMailMock.mock.calls.find(c => + (c[0] as { subject: string }).subject?.includes('Welcome'), + ); + expect(call).toBeDefined(); + const body = (call![0] as { html: string; text: string }); + expect(body.html).not.toMatch(/za_live_[a-f0-9]{48}/); + expect(body.text).not.toMatch(/za_live_[a-f0-9]{48}/); + }); + }); + + describe('duplicate email signup (F-2 partial mitigation)', () => { + beforeEach(() => { + getTenantByEmailMock.mockResolvedValue({ + id: 'tenant-existing', + email: 'existing@example.com', + }); + }); + + it('returns 409 email_taken (status code split is the v1 deferred — see issue #27)', async () => { + const res = await request(app) + .post('/api/console/signup') + .send({ email: 'existing@example.com', password: VALID_PASSWORD }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe('email_taken'); + }); + + it('triggers the notice email to the LEGITIMATE account holder (not the attacker)', async () => { + await request(app) + .post('/api/console/signup') + .send({ email: 'existing@example.com', password: VALID_PASSWORD }); + + await new Promise(resolve => setImmediate(resolve)); + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'existing@example.com', + subject: expect.stringMatching(/Someone tried to sign up/i), + }), + ); + }); + + it('does NOT create a tenant', async () => { + await request(app) + .post('/api/console/signup') + .send({ email: 'existing@example.com', password: VALID_PASSWORD }); + expect(createTenantMock).not.toHaveBeenCalled(); + }); + + it('does NOT leak the tenant id in the 409 response', async () => { + const res = await request(app) + .post('/api/console/signup') + .send({ email: 'existing@example.com', password: VALID_PASSWORD }); + expect(JSON.stringify(res.body)).not.toContain('tenant-existing'); + }); + + it('runs scrypt on the duplicate path (timing equalization)', async () => { + // The check is a wall-clock floor — scrypt at default cost takes + // multiple ms. If the duplicate path returned in <1ms we'd know the + // equalization was skipped. + const t0 = Date.now(); + await request(app) + .post('/api/console/signup') + .send({ email: 'existing@example.com', password: VALID_PASSWORD }); + const elapsed = Date.now() - t0; + // Conservative lower bound — scrypt N=16k r=8 p=1 (Node defaults) + // is ~50ms on commodity hardware. Test machine may be slower; use 10. + expect(elapsed).toBeGreaterThanOrEqual(10); + }); + }); + + describe('invalid input — no enumeration via 400 path', () => { + it('400 invalid_request when email is missing (no DB lookup, no email sent)', async () => { + const res = await request(app) + .post('/api/console/signup') + .send({ password: VALID_PASSWORD }); + expect(res.status).toBe(400); + expect(getTenantByEmailMock).not.toHaveBeenCalled(); + expect(sendMailMock).not.toHaveBeenCalled(); + }); + + it('400 invalid_password when password is too short (no DB lookup)', async () => { + const res = await request(app) + .post('/api/console/signup') + .send({ email: 'x@y.com', password: 'short' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_password'); + expect(getTenantByEmailMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/email.test.ts b/tests/email.test.ts new file mode 100644 index 0000000..77f4ea8 --- /dev/null +++ b/tests/email.test.ts @@ -0,0 +1,253 @@ +/** + * Unit tests for src/services/email.ts + src/services/email-templates.ts. + * + * Mocks nodemailer entirely — no real SMTP. Asserts: + * + * - sendMail() no-ops with a warn when SMTP_HOST is unset + * - sendMail() calls transporter.sendMail with the right shape + * - sendMail() never throws; errors are returned as { ok: false, error } + * - verifySmtp() returns true on transporter.verify success, false on failure + * - email-templates: welcomeEmail + signupAttemptedNoticeEmail return + * { subject, html, text } where html ⊇ text (same information, both + * formats), html contains the Pramaan/IN202311041001 footer, and + * user-supplied strings are HTML-escaped + * - **Critical**: no plaintext API key, no password, no biometric data + * appears anywhere in the rendered output + */ + +const sendMailMock = jest.fn(); +const verifyMock = jest.fn(); + +jest.mock('nodemailer', () => ({ + createTransport: jest.fn(() => ({ + sendMail: sendMailMock, + verify: verifyMock, + })), +})); + +import { config } from '../src/config'; +import { sendMail, verifySmtp, _resetTransporterForTests } from '../src/services/email'; +import { welcomeEmail, signupAttemptedNoticeEmail } from '../src/services/email-templates'; + +describe('services/email', () => { + beforeEach(() => { + sendMailMock.mockReset(); + verifyMock.mockReset(); + _resetTransporterForTests(); + }); + + describe('sendMail — transporter unconfigured', () => { + const originalHost = config.email.smtpHost; + + afterEach(() => { + (config as any).email.smtpHost = originalHost; + }); + + it('returns { ok:false, skipped:true } when SMTP_HOST is empty', async () => { + (config as any).email.smtpHost = ''; + const result = await sendMail({ + to: 'a@b.com', + subject: 's', + html: '

hi

', + text: 'hi', + }); + expect(result).toEqual({ ok: false, skipped: true }); + expect(sendMailMock).not.toHaveBeenCalled(); + }); + + it('returns { ok:false, skipped:true } when SMTP_USER is empty', async () => { + (config as any).email.smtpHost = 'smtp.example.com'; + (config as any).email.smtpUser = ''; + const result = await sendMail({ to: 'a@b.com', subject: 's', html: '

hi

', text: 'hi' }); + expect(result.skipped).toBe(true); + }); + }); + + describe('sendMail — transporter configured', () => { + beforeEach(() => { + (config as any).email.smtpHost = 'smtp.example.com'; + (config as any).email.smtpUser = 'u'; + (config as any).email.smtpPassword = 'p'; + _resetTransporterForTests(); + }); + + it('calls transporter.sendMail with the right envelope', async () => { + sendMailMock.mockResolvedValueOnce({ messageId: '', accepted: ['a@b.com'] }); + const result = await sendMail({ + to: 'a@b.com', + subject: 'Hello', + html: '

hi

', + text: 'hi', + }); + expect(result.ok).toBe(true); + expect(result.messageId).toBe(''); + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'a@b.com', + subject: 'Hello', + html: '

hi

', + text: 'hi', + from: expect.stringContaining(config.email.fromAddress), + }), + ); + }); + + it('honors replyTo when provided', async () => { + sendMailMock.mockResolvedValueOnce({ messageId: '' }); + await sendMail({ to: 'a@b.com', subject: 's', html: 'h', text: 't', replyTo: 'support@x.com' }); + expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'support@x.com' })); + }); + + it('defaults replyTo to EMAIL_FROM', async () => { + sendMailMock.mockResolvedValueOnce({ messageId: '' }); + await sendMail({ to: 'a@b.com', subject: 's', html: 'h', text: 't' }); + expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ replyTo: config.email.fromAddress })); + }); + + it('never throws — SMTP error returns { ok:false, error }', async () => { + sendMailMock.mockRejectedValueOnce(new Error('connection refused')); + const result = await sendMail({ to: 'a@b.com', subject: 's', html: 'h', text: 't' }); + expect(result.ok).toBe(false); + expect(result.error).toBe('connection refused'); + }); + + it('does NOT leak the recipient email into log lines (hashed)', async () => { + // The hashRecipient helper is internal but observable by checking that + // we never raise on a multibyte / unusual address. + sendMailMock.mockResolvedValueOnce({ messageId: '' }); + const result = await sendMail({ to: ' USER@Example.COM ', subject: 's', html: 'h', text: 't' }); + expect(result.ok).toBe(true); + }); + }); + + describe('verifySmtp', () => { + beforeEach(() => { + (config as any).email.smtpHost = 'smtp.example.com'; + (config as any).email.smtpUser = 'u'; + (config as any).email.smtpPassword = 'p'; + _resetTransporterForTests(); + }); + + it('returns true on transporter.verify success', async () => { + verifyMock.mockResolvedValueOnce(true); + expect(await verifySmtp()).toBe(true); + }); + + it('returns false on transporter.verify failure', async () => { + verifyMock.mockRejectedValueOnce(new Error('5.7.1 Unauthorized IP address')); + expect(await verifySmtp()).toBe(false); + }); + + it('returns false when transporter is unconfigured', async () => { + (config as any).email.smtpHost = ''; + _resetTransporterForTests(); + expect(await verifySmtp()).toBe(false); + }); + }); +}); + +describe('services/email-templates', () => { + describe('welcomeEmail', () => { + const input = { email: 'jane@acme.com', companyName: 'Acme Corp', tenantId: 'tenant-1' }; + + it('returns subject + html + text', () => { + const t = welcomeEmail(input); + expect(typeof t.subject).toBe('string'); + expect(t.subject.length).toBeGreaterThan(0); + expect(t.html).toMatch(/<\w+/); // has at least one HTML tag + expect(t.text.length).toBeGreaterThan(0); + }); + + it('mentions the company name when provided', () => { + const t = welcomeEmail(input); + expect(t.html).toContain('Acme Corp'); + expect(t.text).toContain('Acme Corp'); + }); + + it('omits company-specific copy when companyName is null', () => { + const t = welcomeEmail({ ...input, companyName: null }); + expect(t.html).toContain('jane@acme.com'); + expect(t.text).toContain('jane@acme.com'); + expect(t.html).not.toContain('linked to '); + }); + + it('mentions "Pramaan" + the patent number in the footer', () => { + const t = welcomeEmail(input); + expect(t.html).toMatch(/Pramaan/); + expect(t.html).toMatch(/IN202311041001/); + expect(t.text).toMatch(/Pramaan/); + expect(t.text).toMatch(/IN202311041001/); + }); + + it('escapes HTML in companyName to prevent template injection', () => { + const t = welcomeEmail({ ...input, companyName: '' }); + expect(t.html).not.toContain(''); + expect(t.html).toContain('<script>alert(1)</script>'); + }); + + it('escapes quotes in companyName', () => { + const t = welcomeEmail({ ...input, companyName: 'A "B" Co' }); + expect(t.html).toContain('A "B" Co'); + }); + + it('NEVER contains an API key, password, or biometric-derived data (per security-policy §10)', () => { + const t = welcomeEmail(input); + const allText = (t.html + '\n' + t.text).toLowerCase(); + // No za_live_/za_test_ literal in the body (the leak we care about) + expect(allText).not.toMatch(/za_(live|test)_[a-f0-9]{48}/); + // No exposed-credential-value patterns ("password: foo", "api key: bar") + // We deliberately allow the WORDS to appear in copy ("we never email + // plaintext API keys") — what we forbid is value-disclosure shapes. + expect(allText).not.toMatch(/password:\s*\S/); + expect(allText).not.toMatch(/api[_-]?key:\s*\S/); + expect(allText).not.toMatch(/secret:\s*\S/); + expect(allText).not.toMatch(/biometric[_-]?(data|hash|template|embedding):\s*\S/); + }); + + it('links to the dashboard, the Quickstart, and the whitepaper', () => { + const t = welcomeEmail(input); + expect(t.html).toContain('https://zeroauth.dev/dashboard/api-keys'); + expect(t.html).toContain('https://zeroauth.dev/docs/getting-started/quickstart/'); + expect(t.html).toContain('https://zeroauth.dev/docs/whitepaper.pdf'); + }); + }); + + describe('signupAttemptedNoticeEmail', () => { + const input = { email: 'jane@acme.com', attemptIp: '203.0.113.10' }; + + it('returns subject + html + text with the email in the body', () => { + const t = signupAttemptedNoticeEmail(input); + expect(t.html).toContain('jane@acme.com'); + expect(t.text).toContain('jane@acme.com'); + }); + + it('includes the attempt source IP when provided', () => { + const t = signupAttemptedNoticeEmail(input); + expect(t.html).toContain('203.0.113.10'); + expect(t.text).toContain('203.0.113.10'); + }); + + it('omits the IP block when attemptIp is null', () => { + const t = signupAttemptedNoticeEmail({ ...input, attemptIp: null }); + expect(t.html).not.toContain('Attempt source IP'); + expect(t.text).not.toContain('Attempt source IP'); + }); + + it('points the legitimate user to the dashboard login + password-reset flow', () => { + const t = signupAttemptedNoticeEmail(input); + expect(t.html).toContain('https://zeroauth.dev/dashboard/login'); + }); + + it('escapes the source IP value (defense-in-depth — IPs are technically attacker-controlled)', () => { + const t = signupAttemptedNoticeEmail({ ...input, attemptIp: '' }); + expect(t.html).not.toContain(''); + expect(t.html).toContain('<script>x</script>'); + }); + + it('includes Pramaan + patent footer', () => { + const t = signupAttemptedNoticeEmail(input); + expect(t.html).toMatch(/Pramaan/); + expect(t.html).toMatch(/IN202311041001/); + }); + }); +});