@blamejs/core 0.9.49 → 0.10.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +951 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-jmap.js +321 -0
  29. package/lib/guard-managesieve-command.js +566 -0
  30. package/lib/guard-pop3-command.js +317 -0
  31. package/lib/guard-smtp-command.js +58 -3
  32. package/lib/mail-agent.js +20 -7
  33. package/lib/mail-arc-sign.js +12 -8
  34. package/lib/mail-auth.js +323 -34
  35. package/lib/mail-crypto-pgp.js +934 -0
  36. package/lib/mail-crypto-smime.js +340 -0
  37. package/lib/mail-crypto.js +108 -0
  38. package/lib/mail-dav.js +1224 -0
  39. package/lib/mail-deploy.js +492 -0
  40. package/lib/mail-dkim.js +431 -26
  41. package/lib/mail-journal.js +435 -0
  42. package/lib/mail-scan.js +502 -0
  43. package/lib/mail-server-imap.js +64 -26
  44. package/lib/mail-server-jmap.js +488 -0
  45. package/lib/mail-server-managesieve.js +853 -0
  46. package/lib/mail-server-mx.js +40 -30
  47. package/lib/mail-server-pop3.js +836 -0
  48. package/lib/mail-server-rate-limit.js +13 -0
  49. package/lib/mail-server-submission.js +70 -24
  50. package/lib/mail-server-tls.js +445 -0
  51. package/lib/mail-sieve.js +557 -0
  52. package/lib/mail-spam-score.js +284 -0
  53. package/lib/mail.js +99 -0
  54. package/lib/metrics.js +80 -3
  55. package/lib/middleware/dpop.js +58 -3
  56. package/lib/middleware/idempotency-key.js +255 -42
  57. package/lib/middleware/protected-resource-metadata.js +114 -2
  58. package/lib/network-dns-resolver.js +33 -0
  59. package/lib/network-tls.js +46 -0
  60. package/lib/outbox.js +62 -12
  61. package/lib/pqc-agent.js +13 -5
  62. package/lib/retry.js +23 -9
  63. package/lib/router.js +23 -1
  64. package/lib/safe-ical.js +634 -0
  65. package/lib/safe-icap.js +502 -0
  66. package/lib/safe-mime.js +15 -0
  67. package/lib/safe-sieve.js +684 -0
  68. package/lib/safe-smtp.js +57 -0
  69. package/lib/safe-url.js +37 -0
  70. package/lib/safe-vcard.js +473 -0
  71. package/lib/self-update-standalone-verifier.js +32 -3
  72. package/lib/self-update.js +153 -33
  73. package/lib/vendor/MANIFEST.json +161 -156
  74. package/lib/vendor-data.js +127 -9
  75. package/lib/vex.js +324 -59
  76. package/package.json +1 -1
  77. package/sbom.cdx.json +6 -6
@@ -0,0 +1,317 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardPop3Command
4
+ * @nav Guards
5
+ * @title Guard POP3 Command
6
+ * @order 453
7
+ *
8
+ * @intro
9
+ * POP3 command-line validator (RFC 1939 Post Office Protocol — Version 3).
10
+ * Gates every command verb the framework's POP3 listener accepts
11
+ * from peers — `USER` / `PASS` / `APOP` / `AUTH` / `STLS` / `CAPA` /
12
+ * `STAT` / `LIST` / `RETR` / `DELE` / `NOOP` / `RSET` / `TOP` / `UIDL`
13
+ * / `QUIT`.
14
+ *
15
+ * POP3 is a simple line-oriented text protocol. Each command is a
16
+ * single CRLF-terminated line; responses are `+OK ...` or `-ERR ...`
17
+ * single-line or multi-line (terminated by `.` on a line of its own).
18
+ *
19
+ * ## Smuggling defense — bare-CR / bare-LF refusal
20
+ *
21
+ * Same wire-protocol concern as SMTP / IMAP. POP3's `.<CRLF>`
22
+ * end-of-multiline terminator is matched on canonical CRLF only;
23
+ * bare-LF dot-terminators are refused. Command lines themselves
24
+ * must be CRLF-terminated and contain no bare CR or LF.
25
+ *
26
+ * ## STLS injection
27
+ *
28
+ * RFC 2595 STLS upgrade (POP3's equivalent of STARTTLS) is subject
29
+ * to the same pre-handshake command-buffer injection class as
30
+ * SMTP / IMAP STARTTLS (CVE-2021-38371 Exim, CVE-2021-33515
31
+ * Dovecot). This guard refuses trailing payload on the STLS line;
32
+ * the listener's STLS handler is responsible for draining the
33
+ * pre-handshake buffer.
34
+ *
35
+ * ## Per-verb shape
36
+ *
37
+ * RFC 1939 §6 and RFC 2449 §5 define the verbs:
38
+ *
39
+ * - `USER` <name> — single argument
40
+ * - `PASS` <password> — single argument; refuse in CAPA
41
+ * (operator must rely on TLS confidentiality)
42
+ * - `APOP` <name> <digest> — RFC 1939 §7 challenge-response (legacy)
43
+ * - `AUTH` [<sasl-mech>] — RFC 5034 SASL framework (PLAIN /
44
+ * CRAM-MD5 / SCRAM-SHA-256 / EXTERNAL)
45
+ * - `STLS` — RFC 2595 §4 TLS upgrade
46
+ * - `CAPA` — RFC 2449 §5 capability discovery
47
+ * - `STAT` — no args
48
+ * - `LIST` [msg] — optional msg-number argument
49
+ * - `RETR` <msg> — single message-number argument
50
+ * - `DELE` <msg> — single message-number argument
51
+ * - `NOOP` — no args
52
+ * - `RSET` — no args
53
+ * - `TOP` <msg> <n> — RFC 2449 §5 — message + header-line count
54
+ * - `UIDL` [msg] — RFC 1939 §7 — optional msg arg
55
+ * - `QUIT` — no args
56
+ *
57
+ * ## Caps
58
+ *
59
+ * - Command line capped at 255 bytes per RFC 2449 §4 (response
60
+ * lines are 512 octets including CRLF; the command-line cap is
61
+ * even tighter).
62
+ * - Username + password capped at 40 octets each per RFC 1939 §3
63
+ * (longer values accepted under permissive but the wire is
64
+ * interpretation-defined).
65
+ * - Message-number capped at 10-decimal-digit positive integer.
66
+ *
67
+ * Throws `GuardPop3CommandError` on every refusal.
68
+ *
69
+ * @card
70
+ * POP3 command-line validator (RFC 1939 + RFC 2449 capabilities +
71
+ * RFC 2595 STLS + RFC 5034 AUTH). Refuses bare-CR / bare-LF
72
+ * (smuggling defense), caps command-line / username / password / msg
73
+ * bytes, validates per-verb shape.
74
+ */
75
+
76
+ var { defineClass } = require("./framework-error");
77
+
78
+ var GuardPop3CommandError = defineClass("GuardPop3CommandError", { alwaysPermanent: true });
79
+
80
+ var DEFAULT_PROFILE = "strict";
81
+
82
+ var PROFILES = Object.freeze({
83
+ strict: {
84
+ maxLineBytes: 255, // allow:raw-byte-literal — RFC 2449 §4 cap
85
+ maxUsernameBytes: 40, // allow:raw-byte-literal — RFC 1939 §3 cap
86
+ maxPasswordBytes: 40, // allow:raw-byte-literal — RFC 1939 §3 cap
87
+ allowBareLf: false,
88
+ allowApop: false, // RFC 1939 §7 — legacy challenge-response with MD5; refuse under strict (M³AAWG)
89
+ },
90
+ balanced: {
91
+ maxLineBytes: 512, // allow:raw-byte-literal — RFC 2449 §4 response cap
92
+ maxUsernameBytes: 128, // allow:raw-byte-literal — balanced username cap
93
+ maxPasswordBytes: 128, // allow:raw-byte-literal — balanced password cap
94
+ allowBareLf: false,
95
+ allowApop: true,
96
+ },
97
+ permissive: {
98
+ maxLineBytes: 1024, // allow:raw-byte-literal — permissive cap for legacy peers
99
+ maxUsernameBytes: 256, // allow:raw-byte-literal — permissive username cap
100
+ maxPasswordBytes: 256, // allow:raw-byte-literal — permissive password cap
101
+ allowBareLf: true,
102
+ allowApop: true,
103
+ },
104
+ });
105
+
106
+ var COMPLIANCE_POSTURES = Object.freeze({
107
+ hipaa: "strict",
108
+ "pci-dss": "strict",
109
+ gdpr: "strict",
110
+ soc2: "strict",
111
+ });
112
+
113
+ // POP3 verbs per RFC 1939 §6 + RFC 2449 §5 + RFC 2595 §4 + RFC 5034.
114
+ var KNOWN_VERBS = Object.freeze({
115
+ USER: true, PASS: true, APOP: true, AUTH: true, STLS: true,
116
+ CAPA: true, STAT: true, LIST: true, RETR: true, DELE: true,
117
+ NOOP: true, RSET: true, TOP: true, UIDL: true, QUIT: true,
118
+ });
119
+
120
+ var ZERO_ARG_VERBS = Object.freeze({
121
+ STLS: true, CAPA: true, STAT: true, NOOP: true, RSET: true, QUIT: true,
122
+ });
123
+
124
+ var MSG_NUM_RE = /^[1-9][0-9]{0,9}$/; // allow:regex-no-length-cap — anchored + bounded repeat
125
+
126
+ /**
127
+ * @primitive b.guardPop3Command.validate
128
+ * @signature b.guardPop3Command.validate(line, opts?)
129
+ * @since 0.9.52
130
+ * @status stable
131
+ * @related b.guardSmtpCommand.validate, b.guardImapCommand.validate
132
+ *
133
+ * Validate a single POP3 command line (without its CRLF terminator).
134
+ * Returns `{ verb, args }` on success; throws `GuardPop3CommandError`
135
+ * on refusal.
136
+ *
137
+ * @opts
138
+ * profile: "strict" | "balanced" | "permissive",
139
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
140
+ * tls: boolean, // when false + verb is USER/PASS under
141
+ * strict, refuse with `guard-pop3-command/
142
+ * cleartext-auth` (TLS required for credentials)
143
+ *
144
+ * @example
145
+ * var parsed = b.guardPop3Command.validate("USER alice", { tls: true });
146
+ * // → { verb: "USER", args: ["alice"] }
147
+ *
148
+ * var pending = b.guardPop3Command.validate("RETR 12");
149
+ * // → { verb: "RETR", args: ["12"] }
150
+ */
151
+ function validate(line, opts) {
152
+ opts = opts || {};
153
+ var profileName = typeof opts.profile === "string" ? opts.profile : DEFAULT_PROFILE;
154
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
155
+ profileName = COMPLIANCE_POSTURES[opts.posture];
156
+ }
157
+ var caps = PROFILES[profileName];
158
+ if (!caps) {
159
+ throw new GuardPop3CommandError("guard-pop3-command/bad-profile",
160
+ "guardPop3Command.validate: unknown profile '" + profileName + "'");
161
+ }
162
+ if (typeof line !== "string") {
163
+ throw new GuardPop3CommandError("guard-pop3-command/bad-input",
164
+ "guardPop3Command.validate: line must be a string");
165
+ }
166
+ if (line.length === 0) {
167
+ throw new GuardPop3CommandError("guard-pop3-command/empty-line",
168
+ "guardPop3Command.validate: empty command line");
169
+ }
170
+ if (line.length > caps.maxLineBytes) {
171
+ throw new GuardPop3CommandError("guard-pop3-command/line-too-long",
172
+ "guardPop3Command.validate: line " + line.length + " bytes exceeds cap " + caps.maxLineBytes);
173
+ }
174
+ for (var i = 0; i < line.length; i += 1) {
175
+ var c = line.charCodeAt(i);
176
+ if (c === 0x00 || c === 0x7F || (c < 0x20 && c !== 0x09)) { // allow:raw-byte-literal — control-byte refusal
177
+ if (c === 0x0A && caps.allowBareLf) continue;
178
+ throw new GuardPop3CommandError("guard-pop3-command/bad-byte",
179
+ "guardPop3Command.validate: control byte 0x" + c.toString(16) + " at offset " + i); // allow:raw-byte-literal — hex format literal in error message
180
+ }
181
+ }
182
+
183
+ var firstSpace = line.indexOf(" ");
184
+ var verb = (firstSpace === -1 ? line : line.slice(0, firstSpace)).toUpperCase();
185
+ var rest = firstSpace === -1 ? "" : line.slice(firstSpace + 1);
186
+
187
+ if (!KNOWN_VERBS[verb]) {
188
+ throw new GuardPop3CommandError("guard-pop3-command/unknown-verb",
189
+ "guardPop3Command.validate: unknown verb '" + verb + "' (RFC 1939 §6)");
190
+ }
191
+ if (ZERO_ARG_VERBS[verb] && rest.length > 0) {
192
+ throw new GuardPop3CommandError("guard-pop3-command/unexpected-args",
193
+ "guardPop3Command.validate: verb '" + verb + "' takes no arguments");
194
+ }
195
+
196
+ // Per-verb shape — switch dispatch (statically resolved, not
197
+ // dynamic; CodeQL accepts switch as a fixed call graph).
198
+ var args = [];
199
+ switch (verb) {
200
+ case "USER":
201
+ if (!rest) throw new GuardPop3CommandError("guard-pop3-command/missing-username",
202
+ "guardPop3Command.validate: USER requires a name argument");
203
+ if (rest.length > caps.maxUsernameBytes) {
204
+ throw new GuardPop3CommandError("guard-pop3-command/username-too-long",
205
+ "guardPop3Command.validate: USER name " + rest.length + " bytes exceeds cap " + caps.maxUsernameBytes);
206
+ }
207
+ if (opts.tls === false && profileName === "strict") {
208
+ throw new GuardPop3CommandError("guard-pop3-command/cleartext-auth",
209
+ "guardPop3Command.validate: USER refused over cleartext (use STLS first; RFC 2595)");
210
+ }
211
+ args = [rest];
212
+ break;
213
+ case "PASS":
214
+ if (!rest) throw new GuardPop3CommandError("guard-pop3-command/missing-password",
215
+ "guardPop3Command.validate: PASS requires a password argument");
216
+ if (rest.length > caps.maxPasswordBytes) {
217
+ throw new GuardPop3CommandError("guard-pop3-command/password-too-long",
218
+ "guardPop3Command.validate: PASS argument " + rest.length + " bytes exceeds cap " + caps.maxPasswordBytes);
219
+ }
220
+ if (opts.tls === false && profileName === "strict") {
221
+ throw new GuardPop3CommandError("guard-pop3-command/cleartext-auth",
222
+ "guardPop3Command.validate: PASS refused over cleartext (use STLS first; RFC 2595)");
223
+ }
224
+ args = [rest];
225
+ break;
226
+ case "APOP":
227
+ if (!caps.allowApop) {
228
+ throw new GuardPop3CommandError("guard-pop3-command/apop-refused",
229
+ "guardPop3Command.validate: APOP refused under profile '" + profileName +
230
+ "' (RFC 1939 §7 uses MD5 challenge-response; deprecated by M³AAWG)");
231
+ }
232
+ var apopParts = rest.split(" ");
233
+ if (apopParts.length !== 2) {
234
+ throw new GuardPop3CommandError("guard-pop3-command/bad-apop",
235
+ "guardPop3Command.validate: APOP requires `name digest`");
236
+ }
237
+ args = apopParts;
238
+ break;
239
+ case "AUTH":
240
+ // RFC 5034 — `AUTH` alone lists supported mechanisms; `AUTH MECH`
241
+ // initiates a mechanism. Allow either shape.
242
+ args = rest ? rest.split(" ") : [];
243
+ // RFC 2595 §2.1 + RFC 5034 §4 — credentials over cleartext are
244
+ // refused under strict identically to USER/PASS. `AUTH` with no
245
+ // mech argument is a CAPA-style enumeration and stays allowed
246
+ // pre-TLS; a mech-bearing AUTH initiates the credential exchange
247
+ // and MUST be over TLS.
248
+ if (args.length > 0 && opts.tls === false && profileName === "strict") {
249
+ throw new GuardPop3CommandError("guard-pop3-command/cleartext-auth",
250
+ "guardPop3Command.validate: AUTH " + args[0] + " refused over cleartext (use STLS first; RFC 2595 §2.1 + RFC 5034 §4)");
251
+ }
252
+ break;
253
+ case "LIST":
254
+ case "UIDL":
255
+ // Optional msg-number argument.
256
+ if (rest) {
257
+ if (!MSG_NUM_RE.test(rest)) { // allow:regex-no-length-cap — MSG_NUM_RE anchored + bounded
258
+ throw new GuardPop3CommandError("guard-pop3-command/bad-msg-number",
259
+ "guardPop3Command.validate: " + verb + " msg-number must be a positive decimal integer");
260
+ }
261
+ args = [rest];
262
+ }
263
+ break;
264
+ case "RETR":
265
+ case "DELE":
266
+ if (!rest || !MSG_NUM_RE.test(rest)) { // allow:regex-no-length-cap — MSG_NUM_RE anchored + bounded
267
+ throw new GuardPop3CommandError("guard-pop3-command/bad-msg-number",
268
+ "guardPop3Command.validate: " + verb + " requires a positive decimal message-number");
269
+ }
270
+ args = [rest];
271
+ break;
272
+ case "TOP":
273
+ // `TOP msg n` — message + non-negative line-count.
274
+ var topParts = rest.split(" ");
275
+ if (topParts.length !== 2 ||
276
+ !MSG_NUM_RE.test(topParts[0]) || // allow:regex-no-length-cap — MSG_NUM_RE anchored + bounded
277
+ !/^[0-9]{1,10}$/.test(topParts[1])) { // allow:regex-no-length-cap — anchored + bounded line-count
278
+ throw new GuardPop3CommandError("guard-pop3-command/bad-top",
279
+ "guardPop3Command.validate: TOP requires `msg-num line-count` (both decimal)");
280
+ }
281
+ args = topParts;
282
+ break;
283
+ default:
284
+ // STLS / CAPA / STAT / NOOP / RSET / QUIT — ZERO_ARG_VERBS guard
285
+ // above already enforced no-args. Empty args.
286
+ args = [];
287
+ break;
288
+ }
289
+
290
+ return { verb: verb, args: args };
291
+ }
292
+
293
+ /**
294
+ * @primitive b.guardPop3Command.compliancePosture
295
+ * @signature b.guardPop3Command.compliancePosture(posture)
296
+ * @since 0.9.52
297
+ * @status stable
298
+ *
299
+ * Return the effective profile for a compliance posture, or `null`
300
+ * for unknown names.
301
+ *
302
+ * @example
303
+ * b.guardPop3Command.compliancePosture("hipaa"); // → "strict"
304
+ */
305
+ function compliancePosture(posture) {
306
+ return COMPLIANCE_POSTURES[posture] || null;
307
+ }
308
+
309
+ module.exports = {
310
+ validate: validate,
311
+ compliancePosture: compliancePosture,
312
+ PROFILES: PROFILES,
313
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
314
+ KNOWN_VERBS: KNOWN_VERBS,
315
+ ZERO_ARG_VERBS: ZERO_ARG_VERBS,
316
+ GuardPop3CommandError: GuardPop3CommandError,
317
+ };
@@ -259,6 +259,17 @@ function _validateGreeting(verb, rest, caps) {
259
259
  throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
260
260
  verb + " requires a domain or address literal argument (RFC 5321 §4.1.1.1)");
261
261
  }
262
+ // RFC 5321 §4.1.1.1: HELO/EHLO accepts a domain. Real-world MTAs
263
+ // (Postfix, Exim, sendmail) tolerate a single trailing space after
264
+ // the domain — the framework refused it because the DOMAIN_RE
265
+ // doesn't match a domain with trailing whitespace. Strip a single
266
+ // trailing space before the leading-space / double-space check so a
267
+ // legitimate "HELO mail.example.com " passes while abusive multi-
268
+ // space shapes still refuse.
269
+ if (rest.charAt(rest.length - 1) === " " &&
270
+ rest.charAt(rest.length - 2) !== " ") {
271
+ rest = rest.slice(0, -1);
272
+ }
262
273
  // Trim trailing-space tolerance — most peers send a single space; we
263
274
  // accept it but refuse multiple spaces or leading spaces.
264
275
  if (rest.charAt(0) === " " || rest.indexOf(" ") !== -1) {
@@ -506,9 +517,53 @@ function detectBodySmuggling(buf) {
506
517
  throw new GuardSmtpCommandError("guard-smtp-command/bad-input",
507
518
  "detectBodySmuggling: input must be a Buffer");
508
519
  }
509
- for (var i = 1; i < buf.length - 2; i += 1) {
510
- if (buf[i] === 0x0a /* LF */ && buf[i - 1] !== 0x0d /* CR */ &&
511
- buf[i + 1] === 0x2e /* . */ && buf[i + 2] === 0x0a /* LF */) {
520
+ // The CVE-2023-51764 / 51765 / 51766 / 2024-32178 class is any
521
+ // dot-line whose line boundary is anything OTHER than canonical
522
+ // \r\n on BOTH sides of the dot. The canonical-and-only terminator
523
+ // is `\r\n.\r\n`. Every other shape that some receiver might honor
524
+ // is a smuggling vector:
525
+ //
526
+ // shape leading . trailing
527
+ // -------------- ----------- - -------------
528
+ // bare-LF/bare-LF \n . \n ← original detector
529
+ // bare-LF/CRLF \n . \r\n
530
+ // CRLF/bare-LF \r\n . \n ← bare-LF terminator
531
+ // bare-CR/anything \r (no LF) . * ← bare CR (RFC violations)
532
+ //
533
+ // Standalone `.\n` or `\n.\n` at the START of the buffer also
534
+ // count: a dot at byte 0 followed by `\n` would terminate any
535
+ // receiver that accepts bare-LF dot.
536
+ // 0x0a = LF, 0x0d = CR, 0x2e = `.`
537
+ if (buf.length >= 2 && buf[0] === 0x2e && buf[1] === 0x0a) return true;
538
+ // Walk every LF in the buffer. The previous byte must be CR for the
539
+ // line boundary to be canonical; otherwise the line started with
540
+ // bare-LF. If the next bytes are `.` followed by ANY of (LF, CRLF),
541
+ // the shape is a smuggling candidate.
542
+ for (var i = 0; i < buf.length - 1; i += 1) {
543
+ if (buf[i] !== 0x0a) continue;
544
+ var leadingBareLf = (i === 0) || (buf[i - 1] !== 0x0d);
545
+ if (buf[i + 1] !== 0x2e) continue;
546
+ // Trailing terminator shape after the dot:
547
+ // buf[i+2] == LF → bare-LF terminator (always smuggling)
548
+ // buf[i+2] == CR && buf[i+3] == LF → CRLF after dot
549
+ // (only smuggling when the
550
+ // leading boundary was bare-LF)
551
+ if (i + 2 < buf.length && buf[i + 2] === 0x0a) {
552
+ // `.\n` after a bare-LF or CRLF line boundary — both
553
+ // smuggling vectors (CRLF.\n is the v0.9.x-audit case).
554
+ return true;
555
+ }
556
+ if (leadingBareLf && i + 3 < buf.length &&
557
+ buf[i + 2] === 0x0d && buf[i + 3] === 0x0a) {
558
+ // bare-LF.\r\n — smuggling shape (CVE-2023-51764 Postfix).
559
+ return true;
560
+ }
561
+ }
562
+ // Also check for bare-CR-only dot terminators: `\r.\r` (no LF).
563
+ // Some legacy parsers honor bare CR as line terminator.
564
+ for (var j = 0; j < buf.length - 2; j += 1) {
565
+ if (buf[j] === 0x0d && (j + 1 >= buf.length || buf[j + 1] !== 0x0a) &&
566
+ buf[j + 1] === 0x2e && j + 2 < buf.length && buf[j + 2] === 0x0d) {
512
567
  return true;
513
568
  }
514
569
  }
package/lib/mail-agent.js CHANGED
@@ -485,17 +485,30 @@ async function _delete(ctx, args) {
485
485
  }
486
486
 
487
487
  async function _sievePut(ctx, args) {
488
- // Pre-parse shape-only validation lands today; full b.safeSieve
489
- // parse lands v0.9.26 and will be invoked from the throw-stub at
490
- // that slice. The agent-level guard lets operators wire RBAC + name
491
- // shape today.
488
+ // Two-stage validation: agent-level shape guard for RBAC + name +
489
+ // size, then the full RFC 5228 grammar parse via b.safeSieve. The
490
+ // grammar parse refuses unknown / not-yet-implemented capabilities
491
+ // at `require` time (RFC 5228 §3.2) so the operator's persistence
492
+ // step never gets a script the framework can't actually execute.
492
493
  _entry(ctx, "sieve.put", args);
493
494
  guardMailSieve.validate({
494
495
  kind: "put", actor: args.actor, name: args.name, script: args.script,
495
496
  }, { profile: _profileFor(ctx), posture: ctx.posture, ownedNames: args.ownedNames });
496
- throw new MailAgentError("mail-agent/not-implemented",
497
- "agent.sieve.put: full Sieve parser lands at v0.9.26 (b.safeSieve); " +
498
- "shape-only validation passed — wire the persistence step in the operator handler");
497
+ var safeSieve = require("./safe-sieve"); // allow:inline-require — lazy-load until first sieve.put call
498
+ var rv = safeSieve.validate(args.script, {
499
+ profile: _profileFor(ctx),
500
+ compliancePosture: ctx.posture,
501
+ });
502
+ if (!rv.ok) {
503
+ throw new MailAgentError("mail-agent/sieve-parse-error",
504
+ "agent.sieve.put: Sieve script refused — " +
505
+ (rv.issues[0] && rv.issues[0].snippet ? rv.issues[0].snippet : "parse failed"));
506
+ }
507
+ ctx.auditEmit("mail.agent.sieve.put", args && args.actor, {
508
+ name: args.name,
509
+ requiredCaps: rv.requiredCaps,
510
+ });
511
+ return { ok: true, requiredCaps: rv.requiredCaps };
499
512
  }
500
513
 
501
514
  function _notImplemented(ctx, method, args) {
@@ -52,6 +52,7 @@ var nodeCrypto = require("node:crypto");
52
52
  var lazyRequire = require("./lazy-require");
53
53
  var validateOpts = require("./validate-opts");
54
54
  var safeBuffer = require("./safe-buffer");
55
+ var dkim = require("./mail-dkim");
55
56
  var { defineClass } = require("./framework-error");
56
57
 
57
58
  var MailAuthError = defineClass("MailAuthError", { alwaysPermanent: true });
@@ -107,20 +108,23 @@ function _canonRelaxedHeader(name, value) {
107
108
  return name.toLowerCase() + ":" + trimmed + "\r\n";
108
109
  }
109
110
 
111
+ // RFC 8617 §5.1.1 references RFC 6376 §3.4.4 for body canonicalization.
112
+ // The DKIM verifier and signer share `_canonBodyRelaxed`; ARC MUST
113
+ // produce a byte-identical canon so a downstream ARC-verifier (which
114
+ // composes the DKIM verifier per §5.1.1) reaches the same body hash.
115
+ // Earlier inline shape collapsed `[ \t]+` across newlines (the regex
116
+ // is global and not bound per-line), which diverged from DKIM's
117
+ // per-line `safeBuffer.stripTrailingHspace` on a line whose only WSP
118
+ // run sat at the end. Compose the DKIM canon directly.
110
119
  function _canonRelaxedBody(body) {
111
- // Relaxed body canon: collapse runs of WSP within lines, strip
112
- // trailing WSP, remove all trailing empty lines, append single CRLF
113
- // unless body is empty.
114
- if (body.length === 0) return "";
115
- var normalized = body.replace(/\r?\n/g, "\r\n");
116
- var collapsed = normalized.replace(/[ \t]+/g, " ").replace(/[ \t]+\r\n/g, "\r\n");
117
- collapsed = collapsed.replace(/(\r\n)+$/, "");
118
- return collapsed + "\r\n";
120
+ return dkim._canonBodyRelaxedForTest(body || "");
119
121
  }
120
122
 
121
123
  function _bodyHashB64(body, algorithm) {
122
124
  var hashAlgo = algorithm.indexOf("sha256") !== -1 ? "sha256" : "sha512";
123
125
  var canonical = _canonRelaxedBody(body);
126
+ // RFC 6376 §3.4.4 — empty body canon is `\r\n` (one CRLF). Hash
127
+ // includes that CRLF.
124
128
  return nodeCrypto.createHash(hashAlgo).update(canonical).digest("base64");
125
129
  }
126
130