import assert from 'node:assert/strict'; import { mkdtempSync } from 'node:fs'; import net from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { createDomain, createUser, getInboundMailbox, initDatabase, upsertImportedInboundMailbox } from '../src/db.js'; import { startMailboxAccessServers } from '../src/mail-access.js'; import { startSubmissionServer } from '../src/submission.js'; const legacyPassword = 'Vesta-Legacy-Pass!2026'; const legacyPasswordHash = '{MD5}$1$MhVesta1$tmBXF7EjU15TTV.VWRFfl1'; test('Vesta legacy mailbox password authenticates over IMAP, POP3 and SMTP then upgrades without plaintext', async () => { const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-vesta-auth-')), 'vesta-auth-secret'); const user = createUser({ username: 'vesta-auth-user', email: 'vesta-auth-user@example.test', password: 'account-password' }); createDomain(user.id, { domain: 'vesta-auth.example', selector: 'mh', verificationToken: 'verify', dkimPublic: 'public', dkimPrivate: 'private', senderHost: 'mail.vesta-auth.example', sendingIp: '192.0.2.40', spfExtra: '', dmarcPolicy: 'none', dmarcRua: '' }); const mailbox = upsertImportedInboundMailbox(user.id, { address: 'legacy@vesta-auth.example', passwordHash: legacyPasswordHash }); const imported = database .prepare('SELECT password_hash, password_secret FROM inbound_mailboxes WHERE id = ?') .get(mailbox.id); assert.equal(imported.password_hash, legacyPasswordHash); assert.equal(imported.password_secret, ''); const publicMailbox = getInboundMailbox(mailbox.id, user.id); assert.equal(Object.hasOwn(publicMailbox, 'password'), false); assert.equal(Object.hasOwn(publicMailbox, 'passwordHash'), false); assert.equal(Object.hasOwn(publicMailbox, 'passwordSecret'), false); assert.equal(JSON.stringify(publicMailbox).includes(legacyPassword), false); const [imapServer, pop3Server] = startMailboxAccessServers({ hostname: 'mail.vesta-auth.example', imapEnabled: true, imapListeners: [{ port: 0, protocol: 'imap' }], pop3Enabled: true, pop3Listeners: [{ port: 0, protocol: 'pop3' }], allowInsecureAuth: true }); const [smtpServer] = startSubmissionServer({ enabled: true, listeners: [{ port: 0, protocol: 'smtp' }], hostname: 'mail.vesta-auth.example', allowInsecureAuth: true, inboundEnabled: true }); const servers = [imapServer, pop3Server, smtpServer]; await Promise.all(servers.map(waitForListening)); let imapClient; let pop3Client; let smtpClient; try { imapClient = await connectClient(imapServer.address().port); await imapClient.readUntil(/\* OK .* IMAP ready\r\n/); const imapLogin = await imapClient.command( `A1 LOGIN "${mailbox.address}" "${legacyPassword}"`, /A1 (?:OK|NO)/ ); assert.match(imapLogin, /A1 OK LOGIN completed/); const upgraded = database .prepare('SELECT password_hash, password_secret FROM inbound_mailboxes WHERE id = ?') .get(mailbox.id); assert.match(upgraded.password_hash, /^scrypt\$/); assert.notEqual(upgraded.password_hash, legacyPasswordHash); assert.equal(upgraded.password_secret, ''); assert.equal(JSON.stringify(upgraded).includes(legacyPassword), false); await imapClient.command('A2 LOGOUT', /A2 OK/); imapClient.close(); imapClient = null; pop3Client = await connectClient(pop3Server.address().port); await pop3Client.readUntil(/\+OK .* POP3 ready\r\n/); assert.match( await pop3Client.command(`USER ${mailbox.address}`, /\+OK|\-ERR/), /\+OK User accepted/ ); assert.match( await pop3Client.command(`PASS ${legacyPassword}`, /\+OK|\-ERR/), /\+OK Mailbox locked and ready/ ); await pop3Client.command('QUIT', /\+OK Bye/); pop3Client.close(); pop3Client = null; smtpClient = await connectClient(smtpServer.address().port); await smtpClient.readUntil(/^220 .* ready\r\n/m); const ehlo = await smtpClient.command('EHLO client.example', /250 HELP\r\n/); assert.match(ehlo, /250-AUTH PLAIN LOGIN/); const auth = Buffer.from(`\u0000${mailbox.address}\u0000${legacyPassword}`).toString('base64'); assert.match( await smtpClient.command(`AUTH PLAIN ${auth}`, /235 |535 /), /235 Authentication successful/ ); await smtpClient.command('QUIT', /221 Bye/); smtpClient.close(); smtpClient = null; const persisted = getInboundMailbox(mailbox.id, user.id, { includeHash: true, includeSecret: true }); assert.match(persisted.passwordHash, /^scrypt\$/); assert.equal(persisted.passwordSecret, ''); assert.equal(persisted.passwordRecoverable, false); assert.equal(Object.hasOwn(getInboundMailbox(mailbox.id, user.id), 'passwordHash'), false); } finally { imapClient?.close(); pop3Client?.close(); smtpClient?.close(); await Promise.all(servers.map(closeServer)); } }); function connectClient(port) { return new Promise((resolve, reject) => { const socket = net.createConnection({ host: '127.0.0.1', port }); socket.setTimeout(5000); let buffer = ''; const waiters = []; socket.on('data', (chunk) => { buffer += chunk.toString('utf8'); for (const waiter of [...waiters]) { if (!waiter.pattern.test(buffer)) continue; waiters.splice(waiters.indexOf(waiter), 1); const output = buffer; buffer = ''; clearTimeout(waiter.timer); waiter.resolve(output); } }); socket.once('connect', () => resolve({ command(command, pattern) { socket.write(`${command}\r\n`); return this.readUntil(pattern); }, readUntil(pattern) { if (pattern.test(buffer)) { const output = buffer; buffer = ''; return Promise.resolve(output); } return new Promise((waitResolve, waitReject) => { const waiter = { pattern, resolve: waitResolve, timer: null }; waiter.timer = setTimeout(() => { waiters.splice(waiters.indexOf(waiter), 1); waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`)); }, 5000); waiters.push(waiter); }); }, close() { socket.destroy(); } })); socket.once('error', reject); socket.once('timeout', () => reject(new Error('Mail protocol client timed out'))); }); } function waitForListening(server) { if (server.listening) return Promise.resolve(); return new Promise((resolve) => server.once('listening', resolve)); } function closeServer(server) { return new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); }); }