@push.rocks/smartmta 9.0.0 → 9.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,12 +18,15 @@ import type {
18
18
  IRcptToRequestEvent,
19
19
  IScramCredentialRequestEvent,
20
20
  } from '../../security/classes.rustsecuritybridge.js';
21
- import { EmailRouter } from './classes.email.router.js';
21
+ import { EmailRouter, addressGlobToRegExp } from './classes.email.router.js';
22
+ import { assertValidAuthAccounts } from './functions.routing.validation.js';
22
23
  import type {
23
24
  IEmailRoute,
24
25
  IEmailAction,
25
26
  IEmailContext,
26
27
  IEmailDomainConfig,
28
+ ISmtpAuthAccount,
29
+ ISmtpScramVerifier,
27
30
  } from './interfaces.js';
28
31
  import { Email } from '../core/classes.email.js';
29
32
  import { DomainRegistry } from './classes.domain.registry.js';
@@ -61,7 +64,10 @@ export type TRecipientRejectionReason =
61
64
  | 'domain-not-hosted'
62
65
  | 'relay-denied'
63
66
  | 'recipient-hook'
64
- | 'policy-error';
67
+ | 'policy-error'
68
+ | 'sender-scope'
69
+ | 'recipient-scope'
70
+ | 'account-disabled';
65
71
 
66
72
  export interface IResolvedRecipientRoute {
67
73
  recipient: string;
@@ -295,6 +301,11 @@ export interface IUnifiedEmailServerOptions {
295
301
  required?: boolean;
296
302
  methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
297
303
  users?: Array<{username: string, password: string}>;
304
+ /**
305
+ * Authenticated SMTP accounts with hashed credentials and send scopes.
306
+ * Coexists with legacy `users`; accounts win on username collision.
307
+ */
308
+ accounts?: ISmtpAuthAccount[];
298
309
  };
299
310
 
300
311
  // TLS options
@@ -462,6 +473,15 @@ export class UnifiedEmailServer extends EventEmitter {
462
473
  private acceptedEnvelopeDispatchCleanupTimer?: ReturnType<typeof setInterval>;
463
474
  private acceptedEnvelopeDispatchCleanupPromise?: Promise<void>;
464
475
  private acceptedEnvelopeDispatchCleanupCursor?: string;
476
+ private smtpListenerActive = false;
477
+ private smtpListenerShutdown = false;
478
+ private smtpListenerRestartPromise?: Promise<void>;
479
+ private static readonly VERIFIER_DERIVATION_CONCURRENCY = 4;
480
+ private static readonly VERIFIER_DERIVATION_MAX_QUEUE = 32;
481
+ private verifierDerivationActive = 0;
482
+ private verifierDerivationWaiters: Array<() => void> = [];
483
+ /** Compiled scope patterns; keys are config-supplied, bounded by config size. */
484
+ private scopePatternCache: Map<string, RegExp> = new Map();
465
485
 
466
486
  constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
467
487
  super();
@@ -478,6 +498,10 @@ export class UnifiedEmailServer extends EventEmitter {
478
498
  socketTimeout: options.socketTimeout || 60000 // 1 minute
479
499
  };
480
500
 
501
+ // Auth account misconfiguration must fail loudly at configuration time,
502
+ // never degrade into silent deny-all scopes or silent auth failures.
503
+ assertValidAuthAccounts(this.options.auth?.accounts);
504
+
481
505
  // Initialize Rust security bridge (singleton)
482
506
  this.rustBridge = RustSecurityBridge.getInstance();
483
507
 
@@ -927,6 +951,7 @@ export class UnifiedEmailServer extends EventEmitter {
927
951
  */
928
952
  public async start(): Promise<void> {
929
953
  logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
954
+ this.smtpListenerShutdown = false;
930
955
 
931
956
  try {
932
957
  await this.startDeliveryPipeline();
@@ -1169,7 +1194,7 @@ export class UnifiedEmailServer extends EventEmitter {
1169
1194
  maxRecipients: 100,
1170
1195
  connectionTimeoutSecs: this.options.connectionTimeout ? Math.floor(this.options.connectionTimeout / 1000) : 30,
1171
1196
  dataTimeoutSecs: 60,
1172
- authEnabled: !!this.options.auth?.required || !!(this.options.auth?.users?.length),
1197
+ authEnabled: this.isAuthConfigured(this.options),
1173
1198
  maxAuthFailures: 3,
1174
1199
  socketTimeoutSecs: this.options.socketTimeout ? Math.floor(this.options.socketTimeout / 1000) : 300,
1175
1200
  processingTimeoutSecs: this.options.hooks?.onMessageData || this.options.hooks?.onAcceptEnvelope
@@ -1193,9 +1218,57 @@ export class UnifiedEmailServer extends EventEmitter {
1193
1218
  throw new Error('Failed to start Rust SMTP server');
1194
1219
  }
1195
1220
 
1221
+ this.smtpListenerActive = true;
1196
1222
  logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
1197
1223
  }
1198
1224
 
1225
+ /** Whether the current options require SMTP AUTH support on the listener. */
1226
+ private isAuthConfigured(options: IUnifiedEmailServerOptions): boolean {
1227
+ return !!options.auth?.required
1228
+ || !!options.auth?.users?.length
1229
+ || !!options.auth?.accounts?.length;
1230
+ }
1231
+
1232
+ /**
1233
+ * Whether the Rust SMTP listener is currently accepting connections.
1234
+ * Embedders should surface this next to getBridgeState() in health
1235
+ * endpoints: a failed listener reconfiguration leaves it false.
1236
+ */
1237
+ public isSmtpListenerActive(): boolean {
1238
+ return this.smtpListenerActive;
1239
+ }
1240
+
1241
+ /**
1242
+ * Restart only the Rust SMTP listener so listener-level configuration such
1243
+ * as AUTH availability takes effect without a full process restart.
1244
+ * Restarts are serialized and become no-ops once shutdown has begun; a
1245
+ * failed restart leaves the listener inactive, logs loudly, and emits
1246
+ * 'smtpListenerRestartFailed' so embedders can react (e.g. by calling
1247
+ * stop()/start()).
1248
+ */
1249
+ private scheduleSmtpListenerRestart(reason: string): void {
1250
+ const previous = this.smtpListenerRestartPromise || Promise.resolve();
1251
+ this.smtpListenerRestartPromise = previous
1252
+ .then(async () => {
1253
+ if (this.smtpListenerShutdown) return;
1254
+ logger.log('info', `Restarting Rust SMTP listener: ${reason}`);
1255
+ this.smtpListenerActive = false;
1256
+ await this.rustBridge.stopSmtpServer();
1257
+ if (this.smtpListenerShutdown) return;
1258
+ await this.startSmtpServer();
1259
+ })
1260
+ .catch(async (error) => {
1261
+ logger.log(
1262
+ 'error',
1263
+ `Failed to restart Rust SMTP listener (${reason}): ${getErrorMessage(error)}`,
1264
+ );
1265
+ await emitSafely(this, 'smtpListenerRestartFailed', {
1266
+ reason,
1267
+ message: getErrorMessage(error),
1268
+ });
1269
+ });
1270
+ }
1271
+
1199
1272
  /**
1200
1273
  * Stop the unified email server
1201
1274
  */
@@ -1208,6 +1281,18 @@ export class UnifiedEmailServer extends EventEmitter {
1208
1281
  this.acceptedEnvelopeDispatchCleanupTimer = undefined;
1209
1282
  }
1210
1283
  await this.acceptedEnvelopeDispatchCleanupPromise;
1284
+ // Prevent new listener restarts from being scheduled or executed
1285
+ // during shutdown, then drain any in-flight reconfiguration before
1286
+ // tearing down (the chain may be extended while awaited).
1287
+ this.smtpListenerShutdown = true;
1288
+ this.smtpListenerActive = false;
1289
+ while (this.smtpListenerRestartPromise) {
1290
+ const pendingRestart = this.smtpListenerRestartPromise;
1291
+ await pendingRestart;
1292
+ if (this.smtpListenerRestartPromise === pendingRestart) {
1293
+ this.smtpListenerRestartPromise = undefined;
1294
+ }
1295
+ }
1211
1296
  await this.stopDnsValidation();
1212
1297
 
1213
1298
  // Stop the Rust SMTP server first
@@ -1217,6 +1302,7 @@ export class UnifiedEmailServer extends EventEmitter {
1217
1302
  } catch (err) {
1218
1303
  logger.log('warn', `Error stopping Rust SMTP server: ${(err as Error).message}`);
1219
1304
  }
1305
+ this.smtpListenerActive = false;
1220
1306
 
1221
1307
  // Clear the servers array - servers will be garbage collected
1222
1308
  this.servers = [];
@@ -1334,6 +1420,30 @@ export class UnifiedEmailServer extends EventEmitter {
1334
1420
  emailArg?: Email,
1335
1421
  sessionArg?: IExtendedSmtpSession,
1336
1422
  ): Promise<IRecipientPolicyResolution> {
1423
+ // Account scopes are envelope policy and precede route evaluation. This
1424
+ // runs both at RCPT TO and again per recipient at DATA (where the
1425
+ // context is rebuilt from session.envelope truth), so an out-of-scope
1426
+ // MAIL FROM is rejected before any recipient is accepted and cannot be
1427
+ // smuggled past the first phase.
1428
+ const scopeViolation = this.resolveAuthAccountScopeDecision(
1429
+ context.authenticatedUser,
1430
+ context.mailFrom,
1431
+ context.rcptTo,
1432
+ );
1433
+ if (scopeViolation) {
1434
+ const scopeRecipientDomain = this.getAddressDomain(context.rcptTo);
1435
+ return {
1436
+ recipient: context.rcptTo,
1437
+ localDomain: scopeRecipientDomain && this.domainRegistry.isDomainRegistered(scopeRecipientDomain)
1438
+ ? scopeRecipientDomain
1439
+ : null,
1440
+ route: null,
1441
+ source: 'no-route',
1442
+ decision: scopeViolation.decision,
1443
+ rejectionReason: scopeViolation.rejectionReason,
1444
+ };
1445
+ }
1446
+
1337
1447
  const currentRecipientContext = { ...context, acceptedRcptTo: [] };
1338
1448
  const email = emailArg || new Email({
1339
1449
  from: context.mailFrom || '<>',
@@ -1540,6 +1650,156 @@ export class UnifiedEmailServer extends EventEmitter {
1540
1650
  return match?.[1];
1541
1651
  }
1542
1652
 
1653
+ // -----------------------------------------------------------------------
1654
+ // SMTP auth accounts and scopes
1655
+ // -----------------------------------------------------------------------
1656
+
1657
+ /** Resolve the configured account for a username. Accounts win over legacy users. */
1658
+ private findAuthAccount(username: string): ISmtpAuthAccount | undefined {
1659
+ return this.options.auth?.accounts?.find(
1660
+ (account) => account.username === username,
1661
+ );
1662
+ }
1663
+
1664
+ private normalizeScopeAddress(addressArg: string | null | undefined): string {
1665
+ return (addressArg || '').trim().replace(/^<|>$/g, '').toLowerCase();
1666
+ }
1667
+
1668
+ /**
1669
+ * Glob match ('a@b.com' | '*@b.com') against a normalized address, using
1670
+ * the same pattern semantics as route matching. Compiled patterns are
1671
+ * cached; keys are configuration-supplied (never remote-controlled), so
1672
+ * the cache is bounded by account configuration size.
1673
+ */
1674
+ private matchesScopePatterns(
1675
+ addressArg: string | null | undefined,
1676
+ patterns: string[] | undefined,
1677
+ ): boolean {
1678
+ const address = this.normalizeScopeAddress(addressArg);
1679
+ return (patterns || []).some((pattern) => {
1680
+ let regExp = this.scopePatternCache.get(pattern);
1681
+ if (!regExp) {
1682
+ regExp = addressGlobToRegExp(pattern.trim());
1683
+ this.scopePatternCache.set(pattern, regExp);
1684
+ }
1685
+ return regExp.test(address);
1686
+ });
1687
+ }
1688
+
1689
+ /**
1690
+ * Envelope-level scope decision for an authenticated session. Returns null
1691
+ * when the envelope is permitted (unauthenticated sessions, usernames with
1692
+ * no configured account, and accounts without a scope are unrestricted).
1693
+ */
1694
+ private resolveAuthAccountScopeDecision(
1695
+ authenticatedUser: string | null,
1696
+ mailFrom: string,
1697
+ rcptTo: string,
1698
+ ): { decision: ISmtpDecision; rejectionReason: TRecipientRejectionReason } | null {
1699
+ if (!authenticatedUser) return null;
1700
+ const account = this.findAuthAccount(authenticatedUser);
1701
+ if (!account) return null;
1702
+ if (account.enabled === false) {
1703
+ // The account was disabled after authentication succeeded; the session
1704
+ // must not be able to send anything.
1705
+ return {
1706
+ decision: {
1707
+ accepted: false,
1708
+ smtpCode: 550,
1709
+ smtpMessage: '5.7.1 Account disabled',
1710
+ },
1711
+ rejectionReason: 'account-disabled',
1712
+ };
1713
+ }
1714
+ const scope = account.scope;
1715
+ if (!scope) return null;
1716
+ if (!this.matchesScopePatterns(mailFrom, scope.senders)) {
1717
+ return {
1718
+ decision: {
1719
+ accepted: false,
1720
+ smtpCode: 553,
1721
+ smtpMessage: `5.7.1 Sender address not permitted for authenticated user ${authenticatedUser}`,
1722
+ },
1723
+ rejectionReason: 'sender-scope',
1724
+ };
1725
+ }
1726
+ if (
1727
+ scope.recipients?.mode === 'patterns'
1728
+ && !this.matchesScopePatterns(rcptTo, scope.recipients.patterns)
1729
+ ) {
1730
+ return {
1731
+ decision: {
1732
+ accepted: false,
1733
+ smtpCode: 550,
1734
+ smtpMessage: `5.7.1 Recipient not permitted for authenticated user ${authenticatedUser}`,
1735
+ },
1736
+ rejectionReason: 'recipient-scope',
1737
+ };
1738
+ }
1739
+ return null;
1740
+ }
1741
+
1742
+ /**
1743
+ * Extract every mailbox address from the parsed From header. The Email
1744
+ * abstraction keeps only the first From mailbox, which is not sufficient
1745
+ * for scope enforcement: `From: allowed@a.com, victim@b.com` must not
1746
+ * pass. Parsing stops at the first CRLFCRLF header boundary when present
1747
+ * (the Rust receiver normalizes line endings to CRLF); a bodyless message
1748
+ * without one is parsed whole.
1749
+ */
1750
+ private async parseHeaderFromMailboxes(rawMessage: Buffer): Promise<string[]> {
1751
+ const headerBoundary = rawMessage.indexOf('\r\n\r\n');
1752
+ const headerSection = headerBoundary === -1
1753
+ ? rawMessage
1754
+ : rawMessage.subarray(0, headerBoundary + 4);
1755
+ const parsed = await plugins.mailparser.simpleParser(headerSection);
1756
+ return ((parsed.from as any)?.value || [])
1757
+ .map((mailbox: any) => mailbox?.address)
1758
+ .filter(Boolean) as string[];
1759
+ }
1760
+
1761
+ /**
1762
+ * Header-From alignment for scoped authenticated accounts at DATA. The
1763
+ * envelope MAIL FROM is enforced per recipient via resolveRecipientPolicy
1764
+ * from session.envelope truth; every parsed header From mailbox must
1765
+ * independently stay in scope because route matching and downstream
1766
+ * consumers see the header identity.
1767
+ */
1768
+ private async checkAuthenticatedHeaderFromScope(
1769
+ email: Email,
1770
+ rawMessage: Buffer,
1771
+ session: IExtendedSmtpSession,
1772
+ ): Promise<ISmtpDecision | null> {
1773
+ const username = session.user?.username;
1774
+ if (!username) return null;
1775
+ const account = this.findAuthAccount(username);
1776
+ // Disabled accounts and envelope violations are rejected by
1777
+ // resolveRecipientPolicy; this check only adds header alignment.
1778
+ if (!account || account.enabled === false || !account.scope) return null;
1779
+ let fromMailboxes: string[];
1780
+ try {
1781
+ fromMailboxes = await this.parseHeaderFromMailboxes(rawMessage);
1782
+ } catch {
1783
+ fromMailboxes = [];
1784
+ }
1785
+ if (fromMailboxes.length === 0) {
1786
+ // No parsable From header: Email.from falls back to the envelope
1787
+ // MAIL FROM, which resolveRecipientPolicy has already scope-checked.
1788
+ fromMailboxes = [email.getFromAddress()];
1789
+ }
1790
+ const outOfScope = fromMailboxes.find(
1791
+ (mailbox) => !this.matchesScopePatterns(mailbox, account.scope!.senders),
1792
+ );
1793
+ if (outOfScope === undefined) {
1794
+ return null;
1795
+ }
1796
+ return {
1797
+ accepted: false,
1798
+ smtpCode: 553,
1799
+ smtpMessage: `5.7.1 Header From address not permitted for authenticated user ${username}`,
1800
+ };
1801
+ }
1802
+
1543
1803
  private getRecipientValidationTimeoutMs(): number {
1544
1804
  return this.options.smtp?.recipientValidationTimeoutMs || 5000;
1545
1805
  }
@@ -2323,6 +2583,29 @@ export class UnifiedEmailServer extends EventEmitter {
2323
2583
 
2324
2584
 
2325
2585
  const email = await this.createEmailFromBuffer(rawMessageBuffer, session);
2586
+
2587
+ // Header-From alignment for scoped authenticated accounts must be
2588
+ // enforced before any durable acceptance or routing: the RCPT phase
2589
+ // only ever saw the envelope MAIL FROM.
2590
+ const headerFromViolation = await this.checkAuthenticatedHeaderFromScope(
2591
+ email,
2592
+ rawMessageBuffer,
2593
+ session,
2594
+ );
2595
+ if (headerFromViolation) {
2596
+ logger.log(
2597
+ 'warn',
2598
+ `Rejected DATA from authenticated user ${session.user?.username}: ${headerFromViolation.smtpMessage}`,
2599
+ );
2600
+ await this.rustBridge.sendEmailProcessingResult({
2601
+ correlationId,
2602
+ accepted: false,
2603
+ smtpCode: headerFromViolation.smtpCode || 553,
2604
+ smtpMessage: headerFromViolation.smtpMessage || 'Header From not permitted',
2605
+ });
2606
+ return;
2607
+ }
2608
+
2326
2609
  const resolvedRecipientRoutes = await this.resolveDataRecipientRoutes(email, session);
2327
2610
  const rejectedRecipients = resolvedRecipientRoutes.filter(
2328
2611
  (resolution) => !resolution.decision.accepted,
@@ -2549,20 +2832,161 @@ export class UnifiedEmailServer extends EventEmitter {
2549
2832
  }
2550
2833
 
2551
2834
  /**
2552
- * Handle an authRequest event from the Rust SMTP server.
2835
+ * Compute SCRAM-SHA-256 credential fields from a plaintext password with a
2836
+ * fresh random salt (used for legacy plaintext credentials only; verifier
2837
+ * accounts return their stored fields directly).
2838
+ */
2839
+ private computeScramCredentials(password: string): {
2840
+ salt: string;
2841
+ iterations: number;
2842
+ storedKey: string;
2843
+ serverKey: string;
2844
+ } {
2845
+ const salt = plugins.crypto.randomBytes(16);
2846
+ const iterations = 4096;
2847
+
2848
+ // SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
2849
+ const saltedPassword = plugins.crypto.pbkdf2Sync(password, salt, iterations, 32, 'sha256');
2850
+
2851
+ // ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
2852
+ const clientKey = plugins.crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
2853
+
2854
+ // StoredKey = SHA256(ClientKey)
2855
+ const storedKey = plugins.crypto.createHash('sha256').update(clientKey).digest();
2856
+
2857
+ // ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
2858
+ const serverKey = plugins.crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
2859
+
2860
+ return {
2861
+ salt: salt.toString('base64'),
2862
+ iterations,
2863
+ storedKey: storedKey.toString('base64'),
2864
+ serverKey: serverKey.toString('base64'),
2865
+ };
2866
+ }
2867
+
2868
+ /**
2869
+ * Bound concurrent PBKDF2 derivations so credential-stuffing pressure at
2870
+ * high configured iteration counts cannot exhaust the shared libuv
2871
+ * threadpool. Returns false when the wait queue is saturated (fail fast).
2872
+ */
2873
+ private async acquireVerifierDerivationSlot(): Promise<boolean> {
2874
+ if (this.verifierDerivationActive < UnifiedEmailServer.VERIFIER_DERIVATION_CONCURRENCY) {
2875
+ this.verifierDerivationActive++;
2876
+ return true;
2877
+ }
2878
+ if (this.verifierDerivationWaiters.length >= UnifiedEmailServer.VERIFIER_DERIVATION_MAX_QUEUE) {
2879
+ return false;
2880
+ }
2881
+ await new Promise<void>((resolve) => {
2882
+ this.verifierDerivationWaiters.push(resolve);
2883
+ });
2884
+ // Slot ownership was handed over directly by the releaser.
2885
+ return true;
2886
+ }
2887
+
2888
+ private releaseVerifierDerivationSlot(): void {
2889
+ const nextWaiter = this.verifierDerivationWaiters.shift();
2890
+ if (nextWaiter) {
2891
+ // Hand the active slot to the waiter without decrementing so the
2892
+ // concurrency bound is exact even across microtask gaps.
2893
+ nextWaiter();
2894
+ return;
2895
+ }
2896
+ this.verifierDerivationActive--;
2897
+ }
2898
+
2899
+ /**
2900
+ * Verify a presented plaintext password against a stored SCRAM verifier by
2901
+ * deriving StoredKey from the presented password and comparing in constant
2902
+ * time. PBKDF2 runs on the async crypto threadpool so caller-configured
2903
+ * iteration counts never block the event loop inside the Rust auth
2904
+ * callback window; iteration bounds are enforced at configuration time and
2905
+ * in-flight derivations are bounded by a small semaphore.
2906
+ */
2907
+ private async verifyPasswordAgainstScramVerifier(
2908
+ password: string,
2909
+ verifier: ISmtpScramVerifier,
2910
+ ): Promise<boolean> {
2911
+ const acquired = await this.acquireVerifierDerivationSlot();
2912
+ if (!acquired) {
2913
+ logger.log(
2914
+ 'warn',
2915
+ 'SCRAM verifier derivation queue is saturated; failing authentication fast',
2916
+ );
2917
+ return false;
2918
+ }
2919
+ try {
2920
+ const salt = Buffer.from(verifier.salt, 'base64');
2921
+ const storedKey = Buffer.from(verifier.storedKey, 'base64');
2922
+ const saltedPassword = await new Promise<Buffer>((resolve, reject) => {
2923
+ plugins.crypto.pbkdf2(
2924
+ password,
2925
+ salt,
2926
+ verifier.iterations,
2927
+ 32,
2928
+ 'sha256',
2929
+ (error, derivedKey) => (error ? reject(error) : resolve(derivedKey)),
2930
+ );
2931
+ });
2932
+ const clientKey = plugins.crypto.createHmac('sha256', saltedPassword)
2933
+ .update('Client Key')
2934
+ .digest();
2935
+ const candidateStoredKey = plugins.crypto.createHash('sha256')
2936
+ .update(clientKey)
2937
+ .digest();
2938
+ if (candidateStoredKey.length !== storedKey.length) {
2939
+ return false;
2940
+ }
2941
+ return plugins.crypto.timingSafeEqual(candidateStoredKey, storedKey);
2942
+ } catch {
2943
+ return false;
2944
+ } finally {
2945
+ this.releaseVerifierDerivationSlot();
2946
+ }
2947
+ }
2948
+
2949
+ /** Constant-time string comparison for legacy plaintext credentials. */
2950
+ private timingSafeStringEqual(left: string, right: string): boolean {
2951
+ const leftBuffer = Buffer.from(left, 'utf8');
2952
+ const rightBuffer = Buffer.from(right, 'utf8');
2953
+ if (leftBuffer.length !== rightBuffer.length) {
2954
+ // Burn comparable time so length mismatches are not a fast path.
2955
+ plugins.crypto.timingSafeEqual(leftBuffer, leftBuffer);
2956
+ return false;
2957
+ }
2958
+ return plugins.crypto.timingSafeEqual(leftBuffer, rightBuffer);
2959
+ }
2960
+
2961
+ /**
2962
+ * Handle an authRequest event from the Rust SMTP server (PLAIN/LOGIN).
2963
+ * Accounts win over legacy users on username collision; disabled accounts
2964
+ * always fail.
2553
2965
  */
2554
2966
  private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
2555
2967
  const { correlationId, username, password, remoteAddr } = data;
2556
2968
 
2557
2969
  logger.log('info', `Rust SMTP auth request for user=${username} from=${remoteAddr}`);
2558
2970
 
2559
- // Check against configured users
2560
- const users = this.options.auth?.users || [];
2561
- const matched = users.find(
2562
- u => u.username === username && u.password === password
2563
- );
2971
+ const account = this.findAuthAccount(username);
2972
+ let success = false;
2973
+ if (account) {
2974
+ if (account.enabled === false) {
2975
+ success = false;
2976
+ } else if ('verifier' in account.credential) {
2977
+ success = await this.verifyPasswordAgainstScramVerifier(password, account.credential.verifier);
2978
+ } else {
2979
+ success = this.timingSafeStringEqual(account.credential.password, password);
2980
+ }
2981
+ } else {
2982
+ // Legacy users array keeps its existing behavior.
2983
+ const users = this.options.auth?.users || [];
2984
+ success = users.some(
2985
+ u => u.username === username && u.password === password
2986
+ );
2987
+ }
2564
2988
 
2565
- if (matched) {
2989
+ if (success) {
2566
2990
  await this.rustBridge.sendAuthResult({
2567
2991
  correlationId,
2568
2992
  success: true,
@@ -2579,14 +3003,43 @@ export class UnifiedEmailServer extends EventEmitter {
2579
3003
 
2580
3004
  /**
2581
3005
  * Handle a SCRAM credential request from the Rust SMTP server.
2582
- * Computes SCRAM-SHA-256 credentials from the stored password for the given user.
3006
+ * Verifier accounts return their stored fields directly; plaintext
3007
+ * credentials (accounts or legacy users) derive fields on the fly.
2583
3008
  */
2584
3009
  private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
2585
3010
  const { correlationId, username, remoteAddr } = data;
2586
- const crypto = await import('crypto');
2587
3011
 
2588
3012
  logger.log('info', `SCRAM credential request for user=${username} from=${remoteAddr}`);
2589
3013
 
3014
+ const account = this.findAuthAccount(username);
3015
+ if (account) {
3016
+ if (account.enabled === false) {
3017
+ await this.rustBridge.sendScramCredentialResult({
3018
+ correlationId,
3019
+ found: false,
3020
+ });
3021
+ return;
3022
+ }
3023
+ if ('verifier' in account.credential) {
3024
+ const { salt, iterations, storedKey, serverKey } = account.credential.verifier;
3025
+ await this.rustBridge.sendScramCredentialResult({
3026
+ correlationId,
3027
+ found: true,
3028
+ salt,
3029
+ iterations,
3030
+ storedKey,
3031
+ serverKey,
3032
+ });
3033
+ return;
3034
+ }
3035
+ await this.rustBridge.sendScramCredentialResult({
3036
+ correlationId,
3037
+ found: true,
3038
+ ...this.computeScramCredentials(account.credential.password),
3039
+ });
3040
+ return;
3041
+ }
3042
+
2590
3043
  const users = this.options.auth?.users || [];
2591
3044
  const matched = users.find(u => u.username === username);
2592
3045
 
@@ -2598,29 +3051,10 @@ export class UnifiedEmailServer extends EventEmitter {
2598
3051
  return;
2599
3052
  }
2600
3053
 
2601
- // Compute SCRAM-SHA-256 credentials from plaintext password
2602
- const salt = crypto.randomBytes(16);
2603
- const iterations = 4096;
2604
-
2605
- // SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
2606
- const saltedPassword = crypto.pbkdf2Sync(matched.password, salt, iterations, 32, 'sha256');
2607
-
2608
- // ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
2609
- const clientKey = crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
2610
-
2611
- // StoredKey = SHA256(ClientKey)
2612
- const storedKey = crypto.createHash('sha256').update(clientKey).digest();
2613
-
2614
- // ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
2615
- const serverKey = crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
2616
-
2617
3054
  await this.rustBridge.sendScramCredentialResult({
2618
3055
  correlationId,
2619
3056
  found: true,
2620
- salt: salt.toString('base64'),
2621
- iterations,
2622
- storedKey: storedKey.toString('base64'),
2623
- serverKey: serverKey.toString('base64'),
3057
+ ...this.computeScramCredentials(matched.password),
2624
3058
  });
2625
3059
  }
2626
3060
 
@@ -2879,9 +3313,14 @@ export class UnifiedEmailServer extends EventEmitter {
2879
3313
  * Update server configuration
2880
3314
  */
2881
3315
  public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
3316
+ // Fail loudly before any option state is mutated.
3317
+ if (options.auth) {
3318
+ assertValidAuthAccounts(options.auth.accounts);
3319
+ }
2882
3320
  const portsChanged = options.ports &&
2883
3321
  (!this.options.ports ||
2884
3322
  JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
3323
+ const authEnabledBefore = this.isAuthConfigured(this.options);
2885
3324
 
2886
3325
  if (portsChanged) {
2887
3326
  this.stop().then(() => {
@@ -2912,6 +3351,26 @@ export class UnifiedEmailServer extends EventEmitter {
2912
3351
  if (options.routes) {
2913
3352
  this.emailRouter.updateRoutes(options.routes);
2914
3353
  }
3354
+
3355
+ if (options.auth) {
3356
+ // Drop compiled patterns from replaced account configurations.
3357
+ this.scopePatternCache.clear();
3358
+ }
3359
+
3360
+ // AUTH availability is listener-level Rust configuration. When the
3361
+ // auth presence transitions (zero -> first account/user or reverse)
3362
+ // the running listener must be restarted for AUTH to take effect
3363
+ // without a full process restart.
3364
+ if (
3365
+ options.auth
3366
+ && this.smtpListenerActive
3367
+ && !this.smtpListenerShutdown
3368
+ && this.isAuthConfigured(this.options) !== authEnabledBefore
3369
+ ) {
3370
+ this.scheduleSmtpListenerRestart(
3371
+ `SMTP AUTH ${this.isAuthConfigured(this.options) ? 'enabled' : 'disabled'} by configuration update`,
3372
+ );
3373
+ }
2915
3374
  }
2916
3375
  }
2917
3376