| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- 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());
- });
- }
|