@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.
@@ -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
  */
@@ -6,4 +6,5 @@ export * from './interfaces.js';
6
6
  export * from './classes.domain.registry.js';
7
7
  export * from './classes.email.action.executor.js';
8
8
  export * from './classes.dkim.manager.js';
9
+ export * from './functions.routing.validation.js';
9
10
 
@@ -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 */
@@ -145,6 +150,59 @@ export interface IEmailAction {
145
150
  };
146
151
  }
147
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
+
148
206
  /**
149
207
  * Context for route evaluation
150
208
  */