@blamejs/core 0.18.53 → 0.18.54

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 (43) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/NOTICE +1 -1
  3. package/README.md +3 -3
  4. package/lib/ai-adverse-decision.js +18 -2
  5. package/lib/codepoint-class.js +72 -0
  6. package/lib/cookies.js +7 -10
  7. package/lib/credential-hash.js +8 -1
  8. package/lib/crypto.js +7 -5
  9. package/lib/guard-auth.js +34 -11
  10. package/lib/guard-filename.js +33 -32
  11. package/lib/guard-managesieve-command.js +49 -9
  12. package/lib/guard-regex.js +3 -5
  13. package/lib/guard-yaml.js +60 -15
  14. package/lib/mail-agent.js +23 -9
  15. package/lib/mail-arc-sign.js +40 -7
  16. package/lib/mail-auth.js +75 -20
  17. package/lib/mail-crypto-pgp.js +1 -1
  18. package/lib/mail-dkim.js +80 -11
  19. package/lib/mail-helo.js +10 -0
  20. package/lib/mail-rbl.js +10 -3
  21. package/lib/mail-send-deliver.js +151 -32
  22. package/lib/mail-server-imap.js +121 -55
  23. package/lib/mail-server-jmap.js +31 -4
  24. package/lib/mail-server-managesieve.js +168 -25
  25. package/lib/mail-server-mx.js +76 -4
  26. package/lib/mail-server-net.js +126 -0
  27. package/lib/mail-server-pop3.js +73 -22
  28. package/lib/mail-server-submission.js +21 -2
  29. package/lib/mail-store.js +33 -11
  30. package/lib/mail.js +355 -17
  31. package/lib/middleware/bearer-auth.js +6 -1
  32. package/lib/middleware/fetch-metadata.js +5 -1
  33. package/lib/middleware/headers.js +7 -10
  34. package/lib/network-dns-resolver.js +71 -8
  35. package/lib/network-dns.js +26 -0
  36. package/lib/network-smtp-policy.js +42 -10
  37. package/lib/redact.js +13 -3
  38. package/lib/retention.js +22 -2
  39. package/lib/vendor/MANIFEST.json +12 -12
  40. package/lib/vendor/blamejs-pki.cjs +278 -40
  41. package/lib/yaml-lex.js +55 -1
  42. package/package.json +1 -1
  43. package/sbom.cdx.json +6 -6
@@ -253,15 +253,13 @@ function _foldSuperscriptDigits(s) {
253
253
  // A `..` segment: the whole name, or bounded by path separators on both sides.
254
254
  // Bounded, not merely present — `..foo` and `a..b` are ordinary names.
255
255
  function _hasTraversalSegment(name) {
256
- for (var i = 0; i + 1 < name.length; i += 1) {
257
- if (name.charAt(i) !== "." || name.charAt(i + 1) !== ".") continue;
256
+ return codepointClass.hasPairWhere(name, ".", ".", function (i) {
258
257
  var beforeOk = i === 0 ||
259
258
  PATH_SEPARATORS.indexOf(name.charAt(i - 1)) !== -1;
260
259
  var afterOk = i + 2 === name.length ||
261
260
  PATH_SEPARATORS.indexOf(name.charAt(i + 2)) !== -1;
262
- if (beforeOk && afterOk) return true;
263
- }
264
- return false;
261
+ return beforeOk && afterOk;
262
+ });
265
263
  }
266
264
 
267
265
  function _hasAnyFolded(name, needles) {
@@ -280,17 +278,13 @@ function _hasUncPrefix(name) {
280
278
 
281
279
  // An NTFS alternate-data-stream suffix: a colon followed by a run with no
282
280
  // further colon and no separator in it, at the very end of the name.
283
- // One wording for every path that refuses a stream-suffixed name. A caller who
284
- // set `adsPolicy: "allow"` can arrive at any of them sanitize in either mode,
285
- // validate, or gate and each has to answer the same question, or the option
286
- // reads as broken from whichever door they happened to use. Saying it in one
287
- // place and only one is how three of the four ended up silent the first time.
288
- //
289
- // `verifyExtractionPath` is deliberately absent: it is the one entry point that
290
- // HONOURS the option, so it never reaches this text.
291
- var ADS_SCOPE_NOTE = "always refused here; adsPolicy \"allow\" applies only " +
292
- "to verifyExtractionPath";
293
- var ADS_SNIPPET = "NTFS alternate data stream syntax (name:stream) — " + ADS_SCOPE_NOTE;
281
+ // One wording for every path that refuses a stream-suffixed name sanitize in
282
+ // either mode, validate, gate, verifyExtractionPath so a caller meets the same
283
+ // answer whichever door they arrive at, and the way out is named where they are
284
+ // standing rather than somewhere else in the documentation.
285
+ var ADS_SNIPPET = "NTFS alternate data stream syntax (name:stream); set " +
286
+ "adsPolicy \"allow\" when the target filesystem is not NTFS and a colon is " +
287
+ "an ordinary filename character there";
294
288
  var ADS_MESSAGE = "filename contains " + ADS_SNIPPET;
295
289
 
296
290
  function _hasAdsSuffix(name) {
@@ -474,9 +468,11 @@ function _detectIssues(input, opts) {
474
468
 
475
469
  // 6. NTFS alternate data streams — `name:stream`. Unconditional: a write to
476
470
  // `name:stream` lands on a hidden stream of the base file rather than the
477
- // file the caller named, which no policy value makes safe.
471
+ // file the caller named on Windows. On the Linux targets `adsPolicy`
472
+ // exists for, a colon is an ordinary filename character, so the operator
473
+ // holds the switch and the finding is suppressed when they set it.
478
474
  {
479
- if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
475
+ if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name) && name.charAt(0) !== "/") {
480
476
  // Only flag when there's a `:` followed by stream-name characters
481
477
  // and we're NOT at the start (relative path indicator).
482
478
  issues.push({
@@ -673,17 +669,21 @@ function _sanitize(input, opts) {
673
669
  name = "_" + name;
674
670
  }
675
671
 
676
- // ADS detection. Not gated on `adsPolicy`, and the message says so: a
677
- // stream-suffixed name is one of the shapes a filename guard always refuses,
678
- // because on Windows the write lands on a hidden stream of the base file
679
- // rather than on a file anyone can see. `adsPolicy` still takes "allow", for
680
- // `verifyExtractionPath` alone, where an operator may be deliberately
681
- // extracting stream-suffixed entries to a root they chose.
672
+ // ADS detection. On Windows a `name:stream` write lands on a hidden stream of
673
+ // the base file rather than on a file anyone can see, so this is refused by
674
+ // default in every profile.
675
+ //
676
+ // It is an opt-out rather than an absolute, because the check is lexical and
677
+ // a colon is an ordinary filename character on the Linux targets these
678
+ // policies exist for: `12:30 notes.txt` has the same shape as an attack and
679
+ // is a timestamped note (#623). Only the operator knows which filesystem the
680
+ // name will be written to, which is why they get the switch.
682
681
  //
683
- // The scope is in the message because it is invisible from the call site: a
684
- // caller who sets "allow" here gets a refusal that, without it, never
685
- // mentions the setting they changed and so reads as the option being broken.
686
- if (_hasAdsSuffix(name)) {
682
+ // An earlier attempt refused regardless and put the scope in the message,
683
+ // which reads as a considered boundary right up until a caller has set every
684
+ // documented opt-out and still cannot store the file. An option that is
685
+ // accepted and documented as opting out must opt out.
686
+ if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name)) {
687
687
  throw _err("filename.ntfs-ads", ADS_MESSAGE);
688
688
  }
689
689
 
@@ -735,9 +735,10 @@ function _sanitize(input, opts) {
735
735
  * // than refusing is safe
736
736
  * reservedCharPolicy: "reject"|"strip"|"allow",
737
737
  * reservedNamePolicy: "reject"|"audit"|"allow",
738
- * adsPolicy: "reject"|"allow", // reject here; "allow"
739
- * // is honoured only
740
- * // by verifyExtractionPath
738
+ * adsPolicy: "reject"|"allow", // "allow" when the target
739
+ * // filesystem is not NTFS
740
+ * // and a colon is ordinary
741
+ * // there (Linux, macOS)
741
742
  * leadingTrailingPolicy: "reject"|"strip"|"allow",
742
743
  * shellExecExtPolicy: "reject"|"audit"|"allow",
743
744
  * pathSeparatorsPolicy: "reject"|"audit"|"allow",
@@ -817,7 +818,7 @@ function _sanitizeStripMode(input, opts) {
817
818
  if (_hasUncPrefix(name)) {
818
819
  throw _err("filename.unc", "UNC path syntax");
819
820
  }
820
- if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
821
+ if (opts.adsPolicy !== "allow" && _hasAdsSuffix(name) && name.charAt(0) !== "/") {
821
822
  throw _err("filename.ntfs-ads", ADS_MESSAGE);
822
823
  }
823
824
  if (Buffer.byteLength(name, "utf8") > opts.maxBytes) {
@@ -102,6 +102,7 @@
102
102
  * under strict (RFC 4954 §4 class), validates per-verb shape.
103
103
  */
104
104
 
105
+ var C = require("./constants");
105
106
  var { defineClass } = require("./framework-error");
106
107
  var gateContract = require("./gate-contract");
107
108
  var codepointClass = require("./codepoint-class");
@@ -112,6 +113,12 @@ var GuardManageSieveCommandError = defineClass("GuardManageSieveCommandError",
112
113
 
113
114
  var DEFAULT_PROFILE = "strict";
114
115
 
116
+ // A SASL token — the AUTHENTICATE initial response and every later response in
117
+ // a multi-step exchange. Bounded far below the script cap because it is a
118
+ // base64 blob of credentials, not a script body, and because the client
119
+ // declaring its size is by definition not yet authenticated.
120
+ var MAX_SASL_TOKEN_BYTES = C.BYTES.kib(4);
121
+
115
122
  var PROFILES = Object.freeze({
116
123
  strict: {
117
124
  maxLineBytes: 8192, // 8 KiB per-line cap (strict)
@@ -339,13 +346,15 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
339
346
  throw new GuardManageSieveCommandError("guard-managesieve-command/literal-plus-refused",
340
347
  "guardManageSieveCommand.validate: LITERAL+ refused under profile '" + profileName + "'");
341
348
  }
342
- // Base64-initial-response cap: bound by the script-name cap
343
- // (initial-response is a SASL token, not a script body; 4 KiB
344
- // is generous).
345
- if (n > 4096) { // 4 KiB SASL initial-response cap
349
+ // A SASL token, not a script body, so it is bounded well below the
350
+ // script cap. The same number bounds the client's LATER responses in a
351
+ // multi-step exchange (b.mail.server.managesieve reads it from
352
+ // MAX_SASL_TOKEN_BYTES): one exchange, one bound, whichever round the
353
+ // token arrives on and whichever representation it uses.
354
+ if (n > MAX_SASL_TOKEN_BYTES) {
346
355
  throw new GuardManageSieveCommandError("guard-managesieve-command/literal-too-large",
347
356
  "guardManageSieveCommand.validate: AUTHENTICATE initial-response " +
348
- n + " bytes exceeds 4096-byte cap");
357
+ n + " bytes exceeds " + MAX_SASL_TOKEN_BYTES + "-byte cap");
349
358
  }
350
359
  literalBytes = n;
351
360
  literalPlus = isPlus;
@@ -478,10 +487,34 @@ function _validateRenamescript(rest, caps) {
478
487
  return { verb: "RENAMESCRIPT", args: [first.value, second.value] };
479
488
  }
480
489
 
481
- // _parseQuotedString — extract a leading `"..."` quoted string from
482
- // `s` and return `{ value, rest }`, where `rest` is whitespace-trimmed.
483
- // Returns null if `s` does not begin with a DQUOTE. RFC 5804 §1.2
484
- // quoted strings allow UTF-8 content and `\"` / `\\` escape sequences.
490
+ /**
491
+ * @primitive b.guardManageSieveCommand.parseQuotedString
492
+ * @signature b.guardManageSieveCommand.parseQuotedString(s)
493
+ * @since 0.18.54
494
+ * @status stable
495
+ * @related b.guardManageSieveCommand.validate, b.mail.server.managesieve.create
496
+ *
497
+ * Read a leading RFC 5804 §1.2 quoted string off `s` and return
498
+ * `{ value, rest }`, where `value` is the unescaped content and `rest` is
499
+ * what follows with leading whitespace removed. Returns `null` when `s` does
500
+ * not begin with a double quote.
501
+ *
502
+ * The production honours the `\"` and `\\` escapes, and refuses NUL, CR and
503
+ * LF inside the quotes: those end a line-oriented protocol record, so a
504
+ * string carrying one would split the command it appears in.
505
+ *
506
+ * Exposed because a `string` also arrives outside a command, as the client's
507
+ * reply to a SASL challenge. That line never reaches `validate`, and a second
508
+ * hand-rolled unquoting there would be free to lose the escape handling and
509
+ * the control-byte refusal this one has. `b.mail.server.managesieve` reads
510
+ * its SASL responses through this.
511
+ *
512
+ * @example
513
+ * b.guardManageSieveCommand.parseQuotedString('"PLAIN" {12+}');
514
+ * // → { value: "PLAIN", rest: "{12+}" }
515
+ * b.guardManageSieveCommand.parseQuotedString("PLAIN");
516
+ * // → null
517
+ */
485
518
  function _parseQuotedString(s) {
486
519
  if (s.length === 0 || s.charCodeAt(0) !== 0x22) return null; // DQUOTE
487
520
  var out = "";
@@ -572,5 +605,12 @@ module.exports = gateContract.defineParser({
572
605
  extra: {
573
606
  KNOWN_VERBS: KNOWN_VERBS,
574
607
  ZERO_ARG_VERBS: ZERO_ARG_VERBS,
608
+ MAX_SASL_TOKEN_BYTES: MAX_SASL_TOKEN_BYTES,
609
+ // The RFC 5804 §1.2 "string" production. The listener needs it for the
610
+ // client's reply to a SASL challenge, which arrives as a bare string on its
611
+ // own line rather than as a command argument — so it never reaches
612
+ // validate(), and hand-rolling a second dquote-strip there would drop this
613
+ // one's escape handling and its refusal of NUL / CR / LF inside the quotes.
614
+ parseQuotedString: _parseQuotedString,
575
615
  },
576
616
  });
@@ -454,8 +454,7 @@ function _scanBraces(src, at) {
454
454
  // running the construct it screens over operator-supplied text, which is the
455
455
  // shape this module exists to keep away from.
456
456
  function _turnsFoldingOn(src) {
457
- for (var i = 0; i + 2 < src.length; i += 1) {
458
- if (src.charAt(i) !== "(" || src.charAt(i + 1) !== "?") continue;
457
+ return codepointClass.hasPairWhere(src, "(", "?", function (i) {
459
458
  var at = i + 2;
460
459
  var enablesFold = false;
461
460
  while (at < src.length && _isFlagLetter(src.charAt(at))) {
@@ -466,9 +465,8 @@ function _turnsFoldingOn(src) {
466
465
  at += 1;
467
466
  while (at < src.length && _isFlagLetter(src.charAt(at))) at += 1;
468
467
  }
469
- if (enablesFold && src.charAt(at) === ":") return true;
470
- }
471
- return false;
468
+ return enablesFold && src.charAt(at) === ":";
469
+ });
472
470
  }
473
471
 
474
472
  function _parsePattern(src, flags, budget) {
package/lib/guard-yaml.js CHANGED
@@ -179,17 +179,33 @@ function _hasLeadingZeroOctal(text) {
179
179
 
180
180
  // A merge key with an anchor reference — `<<` then optional whitespace, `:`,
181
181
  // optional whitespace, `*`.
182
- function _hasMergeKeyAlias(text) {
183
- for (var i = 0; i + 1 < text.length; i += 1) {
184
- if (text.charAt(i) !== "<" || text.charAt(i + 1) !== "<") continue;
182
+ // A merge key is `<<: *anchor` — a mapping key, so a structural question, and
183
+ // asked of the lexer rather than of the source.
184
+ //
185
+ // The test is whether a NODE can begin at the `<<`, not whether what follows is
186
+ // well-formed: `<<:*d` with no space after the colon is not a mapping entry and
187
+ // is reported anyway, because that is the shape being smuggled past a parser
188
+ // variant that reads it as a merge. A node cannot begin inside a comment, a
189
+ // quoted body, a block scalar's body, or a plain scalar's continuation line, so
190
+ // one question covers all of them.
191
+ //
192
+ // Enumerating those regions instead was tried and is the wrong shape. Without
193
+ // any of this, `<<: *base` written inside a block scalar — a line of someone's
194
+ // shell script — was reported as a merge key; excluding block bodies and quoted
195
+ // bodies then still reported one written in a comment, on a continuation line,
196
+ // and after a document marker. Same root as the duplicate-key screen (#642): a
197
+ // structural rule reading raw source, and a fix that lists regions is the same
198
+ // mistake with a longer list.
199
+ function _hasMergeKeyAlias(text, nodeStarts) {
200
+ return codepointClass.hasPairWhere(text, "<", "<", function (i) {
201
+ if (nodeStarts && !nodeStarts[i]) return false;
185
202
  var j = i + 2;
186
203
  while (j < text.length && _isSpace(text.charCodeAt(j))) j += 1;
187
- if (text.charAt(j) !== ":") continue;
204
+ if (text.charAt(j) !== ":") return false;
188
205
  j += 1;
189
206
  while (j < text.length && _isSpace(text.charCodeAt(j))) j += 1;
190
- if (text.charAt(j) === "*") return true;
191
- }
192
- return false;
207
+ return text.charAt(j) === "*";
208
+ });
193
209
  }
194
210
 
195
211
  // ---- Profile presets ----
@@ -368,11 +384,18 @@ function _detectIssues(input, opts) {
368
384
  // refused. The mask is index-aligned with the source and the same length, so
369
385
  // every location and line number reported below is still the source's.
370
386
  //
371
- // Only the three sigil scans use it. The value-shaped detectors further down
372
- // (the Norway problem, leading zeros, merge keys) are asking about scalar
373
- // CONTENT, which is exactly what the mask removes, so they keep reading the
374
- // source.
375
- var masked = yamlLex.maskNonStructural(input);
387
+ // Every STRUCTURAL screen uses it: the three sigil scans, the duplicate-key
388
+ // scan, and the merge key. The value-shaped detectors (the Norway problem,
389
+ // leading zeros) are asking about scalar CONTENT, which is exactly what the
390
+ // mask removes, so those keep reading the source.
391
+ //
392
+ // Merge keys were on the wrong side of that line, listed with the
393
+ // value-shaped ones: `<<:` is a mapping key, and reading it from the source
394
+ // reported one written inside a block scalar. The test is not what the rule
395
+ // is looking for but what kind of question it asks — "is this a mapping
396
+ // entry?" is structure however scalar-shaped the token looks.
397
+ var lexed = yamlLex.lexLines(input);
398
+ var masked = lexed.masked;
376
399
 
377
400
  // 1. Tag-injection scan.
378
401
  var tagHits = _scanTags(masked);
@@ -488,7 +511,8 @@ function _detectIssues(input, opts) {
488
511
  }
489
512
 
490
513
  // 6. Merge-key chain depth.
491
- if (opts.mergeKeyPolicy !== "allow" && _hasMergeKeyAlias(input)) {
514
+ if (opts.mergeKeyPolicy !== "allow" &&
515
+ _hasMergeKeyAlias(input, lexed.nodeStarts)) {
492
516
  issues.push({
493
517
  kind: "merge-key",
494
518
  severity: opts.mergeKeyPolicy === "reject" ? "high" : "warn",
@@ -537,12 +561,25 @@ function _detectIssues(input, opts) {
537
561
  // FIRST colon that is followed by whitespace or ends the line, which is where
538
562
  // a value begins — a colon inside the key (a timestamp, a URL) does not end
539
563
  // it unless whitespace follows.
540
- function _mappingEntryAt(line) {
564
+ // `masked` is the same line with every non-structural region blanked by
565
+ // yaml-lex. It decides WHICH colons are mapping separators; the raw line
566
+ // supplies the key text, because the mask blanks that too.
567
+ //
568
+ // Without it, a colon inside a block scalar's body or a quoted value read as a
569
+ // mapping entry, and two such lines inside one scalar were reported as a
570
+ // duplicate key (#642). That is the same root as #631/#632 — a screen deciding
571
+ // structure by reading the source — which survived here because only the sigil
572
+ // scans were moved onto the mask.
573
+ function _mappingEntryAt(line, masked) {
541
574
  var indent = 0;
542
575
  while (indent < line.length && _isSpace(line.charCodeAt(indent))) indent += 1;
543
576
  if (indent === line.length) return null;
544
577
  for (var i = indent; i < line.length; i += 1) {
545
578
  if (line.charAt(i) !== ":") continue;
579
+ // Structural only: the mask keeps a mapping separator and blanks a colon
580
+ // that is part of a scalar. Absent a mask, every colon counts, which is the
581
+ // pre-#642 behaviour and is what the non-masked callers still want.
582
+ if (masked !== undefined && masked.charAt(i) !== ":") continue;
546
583
  var after = line.charCodeAt(i + 1);
547
584
  if (i + 1 < line.length && !_isSpace(after)) continue;
548
585
  if (i === indent) return null; // no key before the colon
@@ -581,6 +618,10 @@ var _splitLines = codepointClass.splitLines;
581
618
  function _detectDuplicateKeysYaml(text) {
582
619
  var dups = Object.create(null);
583
620
  var lines = _splitLines(text);
621
+ // Whether a line carries a mapping entry is a STRUCTURAL question, so it is
622
+ // asked of the lexer's mask rather than of the source. Split the same way, so
623
+ // line i of one is line i of the other.
624
+ var maskedLines = _splitLines(yamlLex.maskNonStructural(text));
584
625
  var indentScopes = Object.create(null);
585
626
  // The sequence items currently open, outermost first, each with the column
586
627
  // its first key sat in. Every key of an item at its top level is filed under
@@ -632,7 +673,11 @@ function _detectDuplicateKeysYaml(text) {
632
673
  // keyIndent is set by this item's first key, wherever it is written.
633
674
  itemStack.push({ dash: dashAt, keyIndent: -1 });
634
675
  }
635
- var entry = _mappingEntryAt(line);
676
+ // Passed straight through, with no `|| ""` fallback: an empty mask line
677
+ // blanks every colon, so a missing one would exempt the line from the screen
678
+ // altogether. `undefined` means "no mask" instead, which counts every colon
679
+ // — over-reporting rather than under-reporting if the two ever desynchronise.
680
+ var entry = _mappingEntryAt(line, maskedLines[i]);
636
681
  if (!entry) continue;
637
682
  var indent = entry.indent;
638
683
  var key = entry.key.trim();
package/lib/mail-agent.js CHANGED
@@ -539,19 +539,33 @@ async function _expunge(ctx, args) {
539
539
  "agent.expunge: { folder, objectIds, [candidateTtlMs] } required");
540
540
  }
541
541
 
542
- // Look up the regulator-mandated retention floor for the operator's
543
- // active posture. For expunge semantics, the floor IS the minimum
544
- // TTL — messages younger than the floor MUST NOT be hard-deleted,
545
- // even on operator request. Distinct from `b.retention.
546
- // complianceFloor(posture, candidateTtl)` which composes the
547
- // candidate TTL into a max that primitive's "candidate must be
548
- // positive" contract doesn't apply here because expunge means TTL=0.
549
- // Read the floor table directly.
542
+ // Look up the regulator-mandated retention floor for the operator's active
543
+ // posture. For expunge semantics the floor IS the minimum TTL — messages
544
+ // younger than it MUST NOT be hard-deleted, even on operator request.
545
+ //
546
+ // Through b.retention.complianceFloor, not the floor table it wraps. The
547
+ // direct read fell back to zero for anything the table did not contain, so a
548
+ // misspelled posture, a capitalised one ("HIPAA"), or one the table simply
549
+ // does not carry all permitted an unbounded hard delete with no error — a
550
+ // typo refused where retention windows are computed and accepted at the one
551
+ // call that destroys mail permanently. It also read through the prototype:
552
+ // posture "constructor" returned a function, which is truthy, so the `|| 0`
553
+ // fallback kept it and every age comparison against it was nonsense.
554
+ //
555
+ // No posture at all is a legitimate configuration and keeps the zero floor.
556
+ // A posture that was supplied and is not understood now throws.
550
557
  var retentionModule = require("./retention"); // allow:inline-require — lazy-load until first expunge call
551
558
  var posture = (ctx && ctx.posture) || (args && args.posture) || null;
552
559
  var floorMs = 0;
553
560
  if (typeof posture === "string" && posture.length > 0) {
554
- floorMs = retentionModule.COMPLIANCE_RETENTION_FLOOR_MS[posture] || 0;
561
+ try {
562
+ floorMs = retentionModule.complianceFloor(posture);
563
+ } catch (e) {
564
+ throw new MailAgentError("mail-agent/unknown-posture",
565
+ "expunge: posture '" + posture + "' is not a posture the framework knows, " +
566
+ "so no retention floor can be established and the hard delete is refused " +
567
+ "rather than run unbounded: " + ((e && e.message) || String(e)));
568
+ }
555
569
  }
556
570
 
557
571
  // Read message metadata BEFORE invoking hardExpunge so the per-id
@@ -128,7 +128,12 @@ function _bodyHashB64(body, algorithm) {
128
128
  var canonical = _canonRelaxedBody(body);
129
129
  // RFC 6376 §3.4.4 — empty body canon is `\r\n` (one CRLF). Hash
130
130
  // includes that CRLF.
131
- return nodeCrypto.createHash(hashAlgo).update(canonical).digest("base64");
131
+ //
132
+ // latin1, for the reason DKIM uses it: ARC seals a message the same way DKIM
133
+ // signs one, over the OCTETS on the wire. `update(string)` defaults to UTF-8,
134
+ // which re-encodes every high byte and seals a body that is not the one the
135
+ // message carries.
136
+ return nodeCrypto.createHash(hashAlgo).update(canonical, "latin1").digest("base64");
132
137
  }
133
138
 
134
139
  // RFC 8617 §5 — ARC chains MUST NOT exceed 50 hops. The verifier
@@ -182,8 +187,27 @@ function sign(opts) {
182
187
  "headersToSign", "timestamp", "audit", "excludeAarFromAms",
183
188
  ], "mail.arc.sign");
184
189
 
185
- validateOpts.requireNonEmptyString(opts.rfc822, "sign: rfc822",
186
- MailAuthError, "arc-sign/bad-input");
190
+ // A seal covers the OCTETS on the wire, so a caller holding them seals them
191
+ // rather than a UTF-8 decode of them. One code unit per octet from here down.
192
+ //
193
+ // Through `dkim._toWire`, which is the SAME function the DKIM signer uses.
194
+ // Converting the Buffer here by hand and leaving a string untouched was the
195
+ // first version, and it left every non-ASCII string message signed over the
196
+ // low byte of each code unit: the hashing and signing paths below read a
197
+ // string as latin1 octets, so a message that is not already ASCII has to be
198
+ // put into that form before it reaches them, not after. Two functions
199
+ // answering "which bytes is this message" is how they come to disagree.
200
+ var gaveBuffer = Buffer.isBuffer(opts.rfc822);
201
+ if (gaveBuffer) {
202
+ if (opts.rfc822.length === 0) {
203
+ throw new MailAuthError("arc-sign/bad-input",
204
+ "sign: rfc822 must be a non-empty Buffer or string");
205
+ }
206
+ } else {
207
+ validateOpts.requireNonEmptyString(opts.rfc822, "sign: rfc822",
208
+ MailAuthError, "arc-sign/bad-input");
209
+ }
210
+ opts = Object.assign({}, opts, { rfc822: dkim._toWire(opts.rfc822) });
187
211
  if (typeof opts.instance !== "number" || !isFinite(opts.instance) ||
188
212
  opts.instance < 1 || opts.instance > 50 || // RFC 8617 §5 chain bound
189
213
  Math.floor(opts.instance) !== opts.instance) {
@@ -387,7 +411,14 @@ function sign(opts) {
387
411
  "ARC-Seal: " + asValue + "\r\n" +
388
412
  "ARC-Message-Signature: " + amsValue + "\r\n" +
389
413
  "ARC-Authentication-Results: " + aarValue + "\r\n";
390
- var sealedRfc822 = prependedHeaders + opts.rfc822;
414
+ // Returned in the shape it arrived in, so a caller relaying the sealed
415
+ // message relays the octets they handed over. See the same note in
416
+ // lib/mail-dkim.js sign(): a string caller gets their own string back, which
417
+ // is the UTF-8 decode of the wire form rather than the wire form itself.
418
+ var sealedWire = prependedHeaders + opts.rfc822;
419
+ var sealedRfc822 = gaveBuffer
420
+ ? Buffer.from(sealedWire, "latin1")
421
+ : Buffer.from(sealedWire, "latin1").toString("utf8");
391
422
 
392
423
  if (auditOn) {
393
424
  try {
@@ -418,15 +449,17 @@ function sign(opts) {
418
449
  }
419
450
 
420
451
  function _signOne(canonInput, keyObject, algorithm) {
452
+ // The canonicalized header octets, one code unit per octet — same boundary as
453
+ // the body hash above and as DKIM's.
454
+ var toSign = Buffer.from(canonInput, "latin1");
421
455
  if (algorithm === "ed25519-sha256") {
422
456
  // Ed25519 prehash variant — Node's `crypto.sign(null, msg, key)`
423
457
  // accepts the message directly.
424
- return nodeCrypto.sign(null, Buffer.from(canonInput, "utf8"), keyObject)
425
- .toString("base64");
458
+ return nodeCrypto.sign(null, toSign, keyObject).toString("base64");
426
459
  }
427
460
  // RSA-SHA256 / default — createSign + update + sign.
428
461
  var signer = nodeCrypto.createSign("RSA-SHA256");
429
- signer.update(canonInput);
462
+ signer.update(toSign);
430
463
  return signer.sign(keyObject).toString("base64");
431
464
  }
432
465