vesta-legacy-auth.test.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import net from 'node:net';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import { test } from 'node:test';
  7. import {
  8. createDomain,
  9. createUser,
  10. getInboundMailbox,
  11. initDatabase,
  12. upsertImportedInboundMailbox
  13. } from '../src/db.js';
  14. import { startMailboxAccessServers } from '../src/mail-access.js';
  15. import { startSubmissionServer } from '../src/submission.js';
  16. const legacyPassword = 'Vesta-Legacy-Pass!2026';
  17. const legacyPasswordHash = '{MD5}$1$MhVesta1$tmBXF7EjU15TTV.VWRFfl1';
  18. test('Vesta legacy mailbox password authenticates over IMAP, POP3 and SMTP then upgrades without plaintext', async () => {
  19. const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-vesta-auth-')), 'vesta-auth-secret');
  20. const user = createUser({
  21. username: 'vesta-auth-user',
  22. email: 'vesta-auth-user@example.test',
  23. password: 'account-password'
  24. });
  25. createDomain(user.id, {
  26. domain: 'vesta-auth.example',
  27. selector: 'mh',
  28. verificationToken: 'verify',
  29. dkimPublic: 'public',
  30. dkimPrivate: 'private',
  31. senderHost: 'mail.vesta-auth.example',
  32. sendingIp: '192.0.2.40',
  33. spfExtra: '',
  34. dmarcPolicy: 'none',
  35. dmarcRua: ''
  36. });
  37. const mailbox = upsertImportedInboundMailbox(user.id, {
  38. address: 'legacy@vesta-auth.example',
  39. passwordHash: legacyPasswordHash
  40. });
  41. const imported = database
  42. .prepare('SELECT password_hash, password_secret FROM inbound_mailboxes WHERE id = ?')
  43. .get(mailbox.id);
  44. assert.equal(imported.password_hash, legacyPasswordHash);
  45. assert.equal(imported.password_secret, '');
  46. const publicMailbox = getInboundMailbox(mailbox.id, user.id);
  47. assert.equal(Object.hasOwn(publicMailbox, 'password'), false);
  48. assert.equal(Object.hasOwn(publicMailbox, 'passwordHash'), false);
  49. assert.equal(Object.hasOwn(publicMailbox, 'passwordSecret'), false);
  50. assert.equal(JSON.stringify(publicMailbox).includes(legacyPassword), false);
  51. const [imapServer, pop3Server] = startMailboxAccessServers({
  52. hostname: 'mail.vesta-auth.example',
  53. imapEnabled: true,
  54. imapListeners: [{ port: 0, protocol: 'imap' }],
  55. pop3Enabled: true,
  56. pop3Listeners: [{ port: 0, protocol: 'pop3' }],
  57. allowInsecureAuth: true
  58. });
  59. const [smtpServer] = startSubmissionServer({
  60. enabled: true,
  61. listeners: [{ port: 0, protocol: 'smtp' }],
  62. hostname: 'mail.vesta-auth.example',
  63. allowInsecureAuth: true,
  64. inboundEnabled: true
  65. });
  66. const servers = [imapServer, pop3Server, smtpServer];
  67. await Promise.all(servers.map(waitForListening));
  68. let imapClient;
  69. let pop3Client;
  70. let smtpClient;
  71. try {
  72. imapClient = await connectClient(imapServer.address().port);
  73. await imapClient.readUntil(/\* OK .* IMAP ready\r\n/);
  74. const imapLogin = await imapClient.command(
  75. `A1 LOGIN "${mailbox.address}" "${legacyPassword}"`,
  76. /A1 (?:OK|NO)/
  77. );
  78. assert.match(imapLogin, /A1 OK LOGIN completed/);
  79. const upgraded = database
  80. .prepare('SELECT password_hash, password_secret FROM inbound_mailboxes WHERE id = ?')
  81. .get(mailbox.id);
  82. assert.match(upgraded.password_hash, /^scrypt\$/);
  83. assert.notEqual(upgraded.password_hash, legacyPasswordHash);
  84. assert.equal(upgraded.password_secret, '');
  85. assert.equal(JSON.stringify(upgraded).includes(legacyPassword), false);
  86. await imapClient.command('A2 LOGOUT', /A2 OK/);
  87. imapClient.close();
  88. imapClient = null;
  89. pop3Client = await connectClient(pop3Server.address().port);
  90. await pop3Client.readUntil(/\+OK .* POP3 ready\r\n/);
  91. assert.match(
  92. await pop3Client.command(`USER ${mailbox.address}`, /\+OK|\-ERR/),
  93. /\+OK User accepted/
  94. );
  95. assert.match(
  96. await pop3Client.command(`PASS ${legacyPassword}`, /\+OK|\-ERR/),
  97. /\+OK Mailbox locked and ready/
  98. );
  99. await pop3Client.command('QUIT', /\+OK Bye/);
  100. pop3Client.close();
  101. pop3Client = null;
  102. smtpClient = await connectClient(smtpServer.address().port);
  103. await smtpClient.readUntil(/^220 .* ready\r\n/m);
  104. const ehlo = await smtpClient.command('EHLO client.example', /250 HELP\r\n/);
  105. assert.match(ehlo, /250-AUTH PLAIN LOGIN/);
  106. const auth = Buffer.from(`\u0000${mailbox.address}\u0000${legacyPassword}`).toString('base64');
  107. assert.match(
  108. await smtpClient.command(`AUTH PLAIN ${auth}`, /235 |535 /),
  109. /235 Authentication successful/
  110. );
  111. await smtpClient.command('QUIT', /221 Bye/);
  112. smtpClient.close();
  113. smtpClient = null;
  114. const persisted = getInboundMailbox(mailbox.id, user.id, { includeHash: true, includeSecret: true });
  115. assert.match(persisted.passwordHash, /^scrypt\$/);
  116. assert.equal(persisted.passwordSecret, '');
  117. assert.equal(persisted.passwordRecoverable, false);
  118. assert.equal(Object.hasOwn(getInboundMailbox(mailbox.id, user.id), 'passwordHash'), false);
  119. } finally {
  120. imapClient?.close();
  121. pop3Client?.close();
  122. smtpClient?.close();
  123. await Promise.all(servers.map(closeServer));
  124. }
  125. });
  126. function connectClient(port) {
  127. return new Promise((resolve, reject) => {
  128. const socket = net.createConnection({ host: '127.0.0.1', port });
  129. socket.setTimeout(5000);
  130. let buffer = '';
  131. const waiters = [];
  132. socket.on('data', (chunk) => {
  133. buffer += chunk.toString('utf8');
  134. for (const waiter of [...waiters]) {
  135. if (!waiter.pattern.test(buffer)) continue;
  136. waiters.splice(waiters.indexOf(waiter), 1);
  137. const output = buffer;
  138. buffer = '';
  139. clearTimeout(waiter.timer);
  140. waiter.resolve(output);
  141. }
  142. });
  143. socket.once('connect', () => resolve({
  144. command(command, pattern) {
  145. socket.write(`${command}\r\n`);
  146. return this.readUntil(pattern);
  147. },
  148. readUntil(pattern) {
  149. if (pattern.test(buffer)) {
  150. const output = buffer;
  151. buffer = '';
  152. return Promise.resolve(output);
  153. }
  154. return new Promise((waitResolve, waitReject) => {
  155. const waiter = { pattern, resolve: waitResolve, timer: null };
  156. waiter.timer = setTimeout(() => {
  157. waiters.splice(waiters.indexOf(waiter), 1);
  158. waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
  159. }, 5000);
  160. waiters.push(waiter);
  161. });
  162. },
  163. close() {
  164. socket.destroy();
  165. }
  166. }));
  167. socket.once('error', reject);
  168. socket.once('timeout', () => reject(new Error('Mail protocol client timed out')));
  169. });
  170. }
  171. function waitForListening(server) {
  172. if (server.listening) return Promise.resolve();
  173. return new Promise((resolve) => server.once('listening', resolve));
  174. }
  175. function closeServer(server) {
  176. return new Promise((resolve, reject) => {
  177. server.close((error) => error ? reject(error) : resolve());
  178. });
  179. }