@push.rocks/smartmta 8.2.1 → 9.1.0

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.
@@ -1,3 +1,123 @@
1
+ import type { ISmtpAuthAccount, ISmtpScramVerifier } from './interfaces.js';
2
+
3
+ /**
4
+ * PBKDF2 iteration bounds for SCRAM verifiers. The lower bound is the RFC
5
+ * 5802 recommended minimum; the upper bound caps per-attempt CPU so a single
6
+ * AUTH cannot exhaust the Rust bridge's 5s callback window.
7
+ */
8
+ export const SCRAM_MIN_ITERATIONS = 4096;
9
+ export const SCRAM_MAX_ITERATIONS = 1_000_000;
10
+
11
+ const isBase64OfLength = (value: unknown, byteLength?: number): boolean => {
12
+ if (typeof value !== 'string' || value.length === 0) return false;
13
+ try {
14
+ const decoded = Buffer.from(value, 'base64');
15
+ if (decoded.length === 0) return false;
16
+ // Reject strings that silently drop invalid characters on decode.
17
+ if (decoded.toString('base64').replace(/=+$/, '') !== value.replace(/=+$/, '')) {
18
+ return false;
19
+ }
20
+ return byteLength === undefined || decoded.length === byteLength;
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Loudly reject unusable SMTP auth account configurations. Every failure
28
+ * mode here would otherwise degrade into a silent deny-all (empty scope
29
+ * patterns) or a silent authentication failure (malformed verifier keys).
30
+ */
31
+ export function assertValidAuthAccounts(
32
+ accounts: ISmtpAuthAccount[] | undefined,
33
+ ): void {
34
+ if (accounts === undefined) return;
35
+ if (!Array.isArray(accounts)) {
36
+ throw new Error('auth.accounts must be an array');
37
+ }
38
+ const usernames = new Set<string>();
39
+ for (const account of accounts) {
40
+ const username = account?.username;
41
+ if (typeof username !== 'string' || !username.trim()) {
42
+ throw new Error('auth.accounts entries require a non-empty username');
43
+ }
44
+ const owner = `auth account ${username}`;
45
+ if (usernames.has(username)) {
46
+ throw new Error(`${owner} is configured more than once`);
47
+ }
48
+ usernames.add(username);
49
+
50
+ const credential = account.credential as
51
+ | { verifier?: unknown; password?: unknown }
52
+ | undefined;
53
+ const hasVerifier = credential?.verifier !== undefined;
54
+ const hasPassword = credential?.password !== undefined;
55
+ if (hasVerifier === hasPassword) {
56
+ throw new Error(
57
+ `${owner} credential must contain exactly one of verifier or password`,
58
+ );
59
+ }
60
+ if (hasPassword && typeof credential!.password !== 'string') {
61
+ throw new Error(`${owner} credential.password must be a string`);
62
+ }
63
+ if (hasVerifier) {
64
+ const verifier = credential!.verifier as Partial<ISmtpScramVerifier>;
65
+ if (verifier?.algorithm !== 'SCRAM-SHA-256') {
66
+ throw new Error(`${owner} verifier.algorithm must be SCRAM-SHA-256`);
67
+ }
68
+ if (
69
+ !Number.isInteger(verifier.iterations)
70
+ || verifier.iterations! < SCRAM_MIN_ITERATIONS
71
+ || verifier.iterations! > SCRAM_MAX_ITERATIONS
72
+ ) {
73
+ throw new Error(
74
+ `${owner} verifier.iterations must be an integer between ${SCRAM_MIN_ITERATIONS} and ${SCRAM_MAX_ITERATIONS}`,
75
+ );
76
+ }
77
+ if (!isBase64OfLength(verifier.salt)) {
78
+ throw new Error(`${owner} verifier.salt must be non-empty base64`);
79
+ }
80
+ if (!isBase64OfLength(verifier.storedKey, 32)) {
81
+ throw new Error(`${owner} verifier.storedKey must be base64 of a 32-byte key`);
82
+ }
83
+ if (!isBase64OfLength(verifier.serverKey, 32)) {
84
+ throw new Error(`${owner} verifier.serverKey must be base64 of a 32-byte key`);
85
+ }
86
+ }
87
+
88
+ const scope = account.scope;
89
+ if (scope === undefined) continue;
90
+ if (
91
+ !Array.isArray(scope.senders)
92
+ || scope.senders.length === 0
93
+ || scope.senders.some((pattern) => typeof pattern !== 'string' || !pattern.trim())
94
+ ) {
95
+ throw new Error(
96
+ `${owner} scope.senders must be a non-empty array of address patterns`,
97
+ );
98
+ }
99
+ if (scope.recipients !== undefined) {
100
+ if (scope.recipients.mode !== 'any' && scope.recipients.mode !== 'patterns') {
101
+ throw new Error(`${owner} scope.recipients.mode must be any or patterns`);
102
+ }
103
+ if (
104
+ scope.recipients.mode === 'patterns'
105
+ && (
106
+ !Array.isArray(scope.recipients.patterns)
107
+ || scope.recipients.patterns.length === 0
108
+ || scope.recipients.patterns.some(
109
+ (pattern) => typeof pattern !== 'string' || !pattern.trim(),
110
+ )
111
+ )
112
+ ) {
113
+ throw new Error(
114
+ `${owner} scope.recipients.patterns must be a non-empty array when mode is patterns`,
115
+ );
116
+ }
117
+ }
118
+ }
119
+ }
120
+
1
121
  /**
2
122
  * Reject retention values that cannot be represented and scheduled reliably.
3
123
  */
@@ -27,6 +27,11 @@ export interface IEmailMatch {
27
27
  clientIp?: string | string[];
28
28
  /** Require authentication status */
29
29
  authenticated?: boolean;
30
+ /**
31
+ * Require the session to be authenticated as this exact username (or one
32
+ * of these usernames). Unauthenticated sessions never match such routes.
33
+ */
34
+ authenticatedUser?: string | string[];
30
35
 
31
36
  // Optional advanced matching
32
37
  /** Headers to match */
@@ -43,9 +48,21 @@ export interface IEmailMatch {
43
48
  * Action to take when route matches
44
49
  */
45
50
  export interface IEmailAction {
46
- /** Type of action to perform */
51
+ /**
52
+ * Type of action to perform.
53
+ * - `forward`: relay to a configured SMTP host.
54
+ * - `process`: queue for scanning and outbound MX delivery.
55
+ * - `deliver`: terminal local delivery through the durable
56
+ * `hooks.onAcceptEnvelope` acceptance path. Never relays.
57
+ * - `store`: terminal local delivery like `deliver`, with explicit
58
+ * retention configuration.
59
+ * - `reject`: refuse with a configurable SMTP error.
60
+ */
47
61
  type: 'forward' | 'deliver' | 'reject' | 'process' | 'store';
48
- /** Explicitly permits this route to relay to non-local recipients. */
62
+ /**
63
+ * Explicitly permits this route to relay to non-local recipients.
64
+ * Ignored for `deliver` actions, which are local-only and never relay.
65
+ */
49
66
  allowRelay?: boolean;
50
67
 
51
68
  /** Forward action configuration */
@@ -124,7 +141,7 @@ export interface IEmailAction {
124
141
  }>;
125
142
  };
126
143
 
127
- /** Delivery options (applies to forward/process/deliver) */
144
+ /** Delivery options (applies to the queue-backed forward/process actions) */
128
145
  delivery?: {
129
146
  /** Rate limit (messages per minute) */
130
147
  rateLimit?: number;
@@ -133,6 +150,59 @@ export interface IEmailAction {
133
150
  };
134
151
  }
135
152
 
153
+ /**
154
+ * Precomputed SCRAM-SHA-256 verifier for an SMTP auth account. Only derived
155
+ * key material is stored — the plaintext password never has to be persisted.
156
+ *
157
+ * Derivation (RFC 5802): SaltedPassword = PBKDF2-HMAC-SHA256(password, salt,
158
+ * iterations, 32); StoredKey = SHA256(HMAC-SHA256(SaltedPassword, 'Client Key'));
159
+ * ServerKey = HMAC-SHA256(SaltedPassword, 'Server Key').
160
+ */
161
+ export interface ISmtpScramVerifier {
162
+ algorithm: 'SCRAM-SHA-256';
163
+ /** Base64-encoded PBKDF2 salt. */
164
+ salt: string;
165
+ /** PBKDF2 iteration count (4096 to 1000000, validated at configuration time). */
166
+ iterations: number;
167
+ /** Base64-encoded StoredKey. */
168
+ storedKey: string;
169
+ /** Base64-encoded ServerKey. */
170
+ serverKey: string;
171
+ }
172
+
173
+ /**
174
+ * Send-authorization scope for an authenticated SMTP account.
175
+ */
176
+ export interface ISmtpAuthAccountScope {
177
+ /**
178
+ * Address patterns ('a@b.com' | '*@b.com') the account may send AS.
179
+ * Enforced against both the SMTP MAIL FROM envelope sender and the parsed
180
+ * message header From address.
181
+ */
182
+ senders: string[];
183
+ /** Recipient scope ("send TO who"). Absent or mode 'any' = unrestricted. */
184
+ recipients?: {
185
+ mode: 'any' | 'patterns';
186
+ /** Address patterns enforced for every RCPT TO when mode is 'patterns'. */
187
+ patterns?: string[];
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Authenticated SMTP account with hashed credentials and send scopes.
193
+ */
194
+ export interface ISmtpAuthAccount {
195
+ username: string;
196
+ /** Disabled accounts always fail authentication (default: enabled). */
197
+ enabled?: boolean;
198
+ /**
199
+ * Hashed SCRAM-SHA-256 verifier (preferred) or legacy plaintext password.
200
+ */
201
+ credential: { verifier: ISmtpScramVerifier } | { password: string };
202
+ /** Absent scope = unrestricted (backwards compatible). */
203
+ scope?: ISmtpAuthAccountScope;
204
+ }
205
+
136
206
  /**
137
207
  * Context for route evaluation
138
208
  */