import crypto from 'node:crypto'; import { readFileSync } from 'node:fs'; import http from 'node:http'; import { isIP } from 'node:net'; import { authenticateWithRateLimitAsync, authenticationRateLimiter } from './auth-rate-limit.js'; import { verifyInboundMailboxCredentialAsync } from './db.js'; const authPath = '/internal/dovecot/auth'; const defaultBodyLimit = 8 * 1024; const defaultRequestTimeoutMs = 30_000; const defaultAuthCacheTtlMs = 600_000; const defaultAuthCacheMaxEntries = 4096; export function createDovecotAuthServer(options = {}) { const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile)); const limiter = options.authRateLimiter || authenticationRateLimiter; const verifyCredential = options.verifyCredential || verifyInboundMailboxCredentialAsync; const logger = options.logger || console; const requestTimeoutMs = positiveInteger(options.requestTimeoutMs, defaultRequestTimeoutMs); const authCache = options.authCache || new SuccessfulAuthCache({ ttlMs: options.authCacheTtlMs, maxEntries: options.authCacheMaxEntries, secretDigest: sharedSecretDigest }); const inFlightAuth = options.inFlightAuth || new InFlightAuthChecks({ maxEntries: options.inFlightAuthMaxEntries, secretDigest: sharedSecretDigest }); const server = http.createServer((req, res) => { void handleRequest(req, res, { sharedSecretDigest, limiter, verifyCredential, logger, bodyLimit: defaultBodyLimit, authCache, inFlightAuth }); }); server.requestTimeout = requestTimeoutMs; server.headersTimeout = requestTimeoutMs; server.keepAliveTimeout = 1_000; return server; } export function startDovecotAuthServer(options = {}) { const server = createDovecotAuthServer(options); const host = String(options.host || '0.0.0.0'); const port = Number(options.port ?? 3001); server.listen(port, host, () => { options.onListening?.(server); }); return server; } async function handleRequest(req, res, context) { setPrivateHeaders(res); const pathname = requestPathname(req); if (pathname !== authPath) return sendJson(res, 404, { error: 'Not found.' }); if (req.method !== 'POST') { res.setHeader('Allow', 'POST'); return sendJson(res, 405, { error: 'Method not allowed.' }); } if (!validBearerSecret(req.headers.authorization, context.sharedSecretDigest)) { return sendJson(res, 401, { error: 'Unauthorized.' }); } if (requestContentType(req) !== 'application/json') { return sendJson(res, 415, { error: 'Unsupported media type.' }); } const contentLength = parseContentLength(req.headers['content-length']); if (contentLength === null) return sendJson(res, 400, { error: 'Invalid request.' }); if (contentLength > context.bodyLimit) return sendTooLarge(req, res); let body; try { body = await readJson(req, context.bodyLimit); } catch (error) { if (error instanceof RequestTooLargeError) return sendTooLarge(req, res); return sendJson(res, 400, { error: 'Invalid request.' }); } const request = normalizeAuthRequest(body); if (!request) return sendJson(res, 400, { error: 'Invalid request.' }); try { const authenticated = await authenticateWithRateLimitAsync({ limiter: context.limiter, ip: request.remoteIp, account: request.username, authenticate: () => verifyCachedCredential(request, context) }); if (!authenticated) return sendJson(res, 200, { authenticated: false }); return sendJson(res, 200, { authenticated: true, user: authenticated.user }); } catch { context.logger.error?.('Dovecot authentication bridge request failed.'); return sendJson(res, 503, { error: 'Service unavailable.' }); } } async function verifyCachedCredential(request, context) { const cachedUser = context.authCache?.get(request.username, request.password); if (cachedUser) return { user: cachedUser }; return context.inFlightAuth.run(request.username, request.password, async () => { const recheckedUser = context.authCache?.get(request.username, request.password); if (recheckedUser) return { user: recheckedUser }; const authenticated = await context.verifyCredential(request.username, request.password); if (!authenticated) return null; const user = canonicalMailboxAddress(authenticated); if (!user) throw new Error('Credential verifier returned an invalid mailbox'); context.authCache?.set(request.username, request.password, user); return { user }; }); } function readSharedSecret(filePath) { if (!filePath || typeof filePath !== 'string') { throw new Error('Dovecot authentication secret file is required'); } const secret = readFileSync(filePath, 'utf8').trim(); const bytes = Buffer.byteLength(secret, 'utf8'); if (bytes < 32 || bytes > 512 || /\s/.test(secret)) { throw new Error('Dovecot authentication secret must be a 32-512 byte token'); } return secret; } function validBearerSecret(header, expectedDigest) { const match = String(header || '').match(/^Bearer\s+([^\s]+)$/i); const actualDigest = digestSecret(match?.[1] || ''); return Boolean(match) && crypto.timingSafeEqual(actualDigest, expectedDigest); } function digestSecret(value) { return crypto.createHash('sha256').update(value).digest(); } class SuccessfulAuthCache { constructor({ ttlMs = defaultAuthCacheTtlMs, maxEntries = defaultAuthCacheMaxEntries, secretDigest = crypto.randomBytes(32), now = () => Date.now() } = {}) { this.ttlMs = Math.max(0, Number(ttlMs ?? defaultAuthCacheTtlMs) || 0); this.maxEntries = Math.max(0, Number(maxEntries ?? defaultAuthCacheMaxEntries) || 0); this.secretDigest = Buffer.from(secretDigest); this.now = now; this.entries = new Map(); } get(username, password) { if (!this.enabled()) return ''; const key = this.key(username, password); const entry = this.entries.get(key); if (!entry) return ''; if (entry.expiresAt <= this.now()) { this.entries.delete(key); return ''; } this.entries.delete(key); this.entries.set(key, entry); return entry.user; } set(username, password, user) { if (!this.enabled()) return; const cleanUser = String(user || '').trim().toLowerCase(); if (!cleanUser) return; const key = this.key(username, password); this.entries.set(key, { user: cleanUser, expiresAt: this.now() + this.ttlMs }); while (this.entries.size > this.maxEntries) { const oldestKey = this.entries.keys().next().value; if (oldestKey === undefined) break; this.entries.delete(oldestKey); } } enabled() { return this.ttlMs > 0 && this.maxEntries > 0; } key(username, password) { return crypto .createHmac('sha256', this.secretDigest) .update(String(username || '').trim().toLowerCase()) .update('\0') .update(String(password || '')) .digest('hex'); } } class InFlightAuthChecks { constructor({ maxEntries = defaultAuthCacheMaxEntries, secretDigest = crypto.randomBytes(32) } = {}) { this.maxEntries = Math.max(0, Number(maxEntries ?? defaultAuthCacheMaxEntries) || 0); this.secretDigest = Buffer.from(secretDigest); this.entries = new Map(); } run(username, password, authenticate) { if (!this.enabled()) return authenticate(); const key = this.key(username, password); const existing = this.entries.get(key); if (existing) return existing; const pending = Promise.resolve() .then(authenticate) .finally(() => { this.entries.delete(key); }); this.entries.set(key, pending); while (this.entries.size > this.maxEntries) { const oldestKey = this.entries.keys().next().value; if (oldestKey === undefined || oldestKey === key) break; this.entries.delete(oldestKey); } return pending; } enabled() { return this.maxEntries > 0; } key(username, password) { return crypto .createHmac('sha256', this.secretDigest) .update(String(username || '').trim().toLowerCase()) .update('\0') .update(String(password || '')) .digest('hex'); } } function requestPathname(req) { try { return new URL(req.url || '/', 'http://mailhub.internal').pathname; } catch { return ''; } } function requestContentType(req) { return String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase(); } function parseContentLength(value) { if (value === undefined) return 0; const raw = String(value); if (!/^\d+$/.test(raw)) return null; const parsed = Number(raw); return Number.isSafeInteger(parsed) ? parsed : null; } async function readJson(req, limit) { const chunks = []; let bytes = 0; for await (const chunk of req) { bytes += chunk.length; if (bytes > limit) throw new RequestTooLargeError(); chunks.push(chunk); } if (!chunks.length) throw new Error('Request body is required'); const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Object body is required'); return body; } function normalizeAuthRequest(body) { if (typeof body.username !== 'string' || typeof body.password !== 'string') return null; if (typeof body.remoteIp !== 'string') return null; const username = body.username.trim(); const remoteIp = body.remoteIp.trim(); const service = String(body.service || 'imap').trim().toLowerCase(); if (!username || Buffer.byteLength(username, 'utf8') > 320 || /[\r\n\u0000]/.test(username)) return null; if (Buffer.byteLength(body.password, 'utf8') > defaultBodyLimit) return null; if (!isIP(remoteIp) || !['imap', 'pop3'].includes(service)) return null; return { username, password: body.password, remoteIp }; } function canonicalMailboxAddress(authenticated) { const rawAddress = String(authenticated?.mailbox?.address || ''); const address = rawAddress.toLowerCase(); if ( rawAddress !== rawAddress.trim() || Buffer.byteLength(address, 'utf8') > 320 || /[\/\\\u0000\s]/.test(address) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address) ) return ''; return address; } function setPrivateHeaders(res) { res.setHeader('Cache-Control', 'no-store'); res.setHeader('Pragma', 'no-cache'); res.setHeader('X-Content-Type-Options', 'nosniff'); } function sendJson(res, status, payload, headers = {}) { if (res.writableEnded) return; const body = JSON.stringify(payload); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': String(Buffer.byteLength(body)), ...headers }); res.end(body); } function sendTooLarge(req, res) { req.resume(); return sendJson(res, 413, { error: 'Request too large.' }, { Connection: 'close' }); } function positiveInteger(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } class RequestTooLargeError extends Error {}