@blamejs/blamejs-shop 0.3.56 → 0.3.58

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 (84) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/SECURITY.md +11 -0
  4. package/lib/asset-manifest.json +3 -3
  5. package/lib/checkout.js +89 -10
  6. package/lib/security-middleware.js +17 -1
  7. package/lib/storefront.js +156 -51
  8. package/lib/vendor/MANIFEST.json +84 -72
  9. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  10. package/lib/vendor/blamejs/README.md +3 -3
  11. package/lib/vendor/blamejs/SECURITY.md +5 -0
  12. package/lib/vendor/blamejs/api-snapshot.json +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +10 -4
  14. package/lib/vendor/blamejs/lib/ai-prompt.js +1 -1
  15. package/lib/vendor/blamejs/lib/app-shutdown.js +28 -0
  16. package/lib/vendor/blamejs/lib/archive-read.js +215 -16
  17. package/lib/vendor/blamejs/lib/archive.js +206 -52
  18. package/lib/vendor/blamejs/lib/auth/oauth.js +58 -0
  19. package/lib/vendor/blamejs/lib/auth/oid4vci.js +84 -27
  20. package/lib/vendor/blamejs/lib/breach-deadline.js +166 -1
  21. package/lib/vendor/blamejs/lib/cloud-events.js +3 -1
  22. package/lib/vendor/blamejs/lib/codepoint-class.js +21 -0
  23. package/lib/vendor/blamejs/lib/db-schema.js +120 -3
  24. package/lib/vendor/blamejs/lib/db.js +10 -3
  25. package/lib/vendor/blamejs/lib/error-page.js +93 -9
  26. package/lib/vendor/blamejs/lib/external-db.js +164 -13
  27. package/lib/vendor/blamejs/lib/guard-email.js +36 -3
  28. package/lib/vendor/blamejs/lib/http-client.js +37 -7
  29. package/lib/vendor/blamejs/lib/mail-auth.js +554 -55
  30. package/lib/vendor/blamejs/lib/mail-send-deliver.js +15 -5
  31. package/lib/vendor/blamejs/lib/mail-sieve.js +2 -1
  32. package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +88 -19
  33. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +58 -11
  34. package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +56 -4
  35. package/lib/vendor/blamejs/lib/middleware/attach-user.js +45 -10
  36. package/lib/vendor/blamejs/lib/middleware/body-parser.js +70 -14
  37. package/lib/vendor/blamejs/lib/middleware/csp-report.js +30 -2
  38. package/lib/vendor/blamejs/lib/middleware/deny-response.js +29 -9
  39. package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +56 -4
  40. package/lib/vendor/blamejs/lib/middleware/scim-server.js +301 -14
  41. package/lib/vendor/blamejs/lib/openapi-paths-builder.js +105 -29
  42. package/lib/vendor/blamejs/lib/openapi.js +225 -100
  43. package/lib/vendor/blamejs/lib/problem-details.js +15 -3
  44. package/lib/vendor/blamejs/lib/queue-local.js +148 -38
  45. package/lib/vendor/blamejs/lib/queue.js +41 -11
  46. package/lib/vendor/blamejs/lib/render.js +21 -3
  47. package/lib/vendor/blamejs/lib/router.js +13 -6
  48. package/lib/vendor/blamejs/lib/safe-buffer.js +55 -0
  49. package/lib/vendor/blamejs/lib/sse.js +7 -5
  50. package/lib/vendor/blamejs/lib/static.js +46 -17
  51. package/lib/vendor/blamejs/lib/uri-template.js +3 -1
  52. package/lib/vendor/blamejs/package.json +1 -1
  53. package/lib/vendor/blamejs/release-notes/v0.14.17.json +57 -0
  54. package/lib/vendor/blamejs/release-notes/v0.14.18.json +127 -0
  55. package/lib/vendor/blamejs/release-notes/v0.14.19.json +61 -0
  56. package/lib/vendor/blamejs/test/00-primitives.js +151 -0
  57. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +18 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +86 -0
  59. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +58 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +201 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/archive.test.js +179 -0
  62. package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +19 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/attach-user-bearer-scheme.test.js +154 -0
  64. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-chunked-malformed.test.js +10 -8
  65. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-smuggling.test.js +99 -20
  66. package/lib/vendor/blamejs/test/layer-0-primitives/breach-deadline.test.js +85 -0
  67. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +50 -1
  68. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +63 -0
  69. package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +107 -2
  70. package/lib/vendor/blamejs/test/layer-0-primitives/db-schema-drift.test.js +145 -0
  71. package/lib/vendor/blamejs/test/layer-0-primitives/deny-response.test.js +32 -0
  72. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +119 -0
  73. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +121 -1
  74. package/lib/vendor/blamejs/test/layer-0-primitives/guard-email.test.js +14 -0
  75. package/lib/vendor/blamejs/test/layer-0-primitives/http-client-stream.test.js +53 -0
  76. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -5
  77. package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +45 -0
  78. package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +80 -0
  79. package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +177 -0
  80. package/lib/vendor/blamejs/test/layer-0-primitives/queue-byo-db.test.js +312 -0
  81. package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +165 -2
  82. package/lib/vendor/blamejs/test/layer-0-primitives/sse.test.js +33 -1
  83. package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +59 -0
  84. package/package.json +1 -1
@@ -5,7 +5,8 @@
5
5
  * - b.auth.saml (sp.buildAuthnRequest, sp.verifyResponse signature path)
6
6
  * - b.auth.openidFederation (entity statement parse + verify, metadata-policy operators)
7
7
  * - b.auth.oid4vp (DCQL validator + matcher)
8
- * - b.auth.oid4vci (issuer config validation, metadata, offer creation shape)
8
+ * - b.auth.oid4vci (issuer config validation, metadata, offer creation shape,
9
+ * kid-only proof resolution via resolveKid)
9
10
  * - b.auth.ciba (client.create validation + binding-message rules)
10
11
  * - b.auth.sdJwtVc (key_attestation header surfaces through present)
11
12
  * - b.session.isAnonymous + b.session.create({ anonymous: true })
@@ -281,6 +282,124 @@ function testOid4vciIssuerConfig() {
281
282
  check("OID4VCI: metadata has configurations", !!meta.credential_configurations_supported["id-card-1"]);
282
283
  }
283
284
 
285
+ // ---- OID4VCI kid-only proof via resolveKid ---------------------------
286
+
287
+ // Build a holder-signed openid4vci-proof+jwt with a `kid` header and no
288
+ // inline `jwk` (the EUDI-Wallet attested-key shape). ES256 over the
289
+ // holder EC key.
290
+ function _b64url(buf) {
291
+ return Buffer.from(buf).toString("base64url");
292
+ }
293
+ function _signKidProof(holderPrivKey, kid, aud, nonce) {
294
+ var header = { typ: "openid4vci-proof+jwt", alg: "ES256", kid: kid };
295
+ var payload = { aud: aud, nonce: nonce, iat: Math.floor(Date.now() / 1000) };
296
+ var signingInput = _b64url(JSON.stringify(header)) + "." + _b64url(JSON.stringify(payload));
297
+ var sig = nodeCrypto.sign("sha256", Buffer.from(signingInput, "ascii"),
298
+ { key: holderPrivKey, dsaEncoding: "ieee-p1363" });
299
+ return signingInput + "." + _b64url(sig);
300
+ }
301
+
302
+ async function _runIssuanceWithProof(issuerOpts, proofForNonce) {
303
+ var captured = { holderKey: null };
304
+ var iss = b.auth.oid4vci.issuer.create(Object.assign({
305
+ credentialIssuerUrl: "https://issuer.example",
306
+ credentialEndpoint: "https://issuer.example/credential",
307
+ tokenEndpoint: "https://issuer.example/token",
308
+ sdJwtIssuer: { issue: async function (a) { captured.holderKey = a.holderKey; return { token: "fake-sd-jwt" }; } },
309
+ supportedCredentials: {
310
+ "id-card-1": { format: "vc+sd-jwt", vct: "https://example.com/vct/identity",
311
+ claims: { given_name: {} } },
312
+ },
313
+ }, issuerOpts));
314
+ var offer = await iss.createCredentialOffer({ subject: "user-7", credentialIds: ["id-card-1"] });
315
+ var tokens = await iss.exchangePreAuthorizedCode({ preAuthCode: offer.preAuthCode });
316
+ var rv = await iss.issueCredential({
317
+ accessToken: tokens.access_token,
318
+ credentialIdentifier: "id-card-1",
319
+ proof: proofForNonce(tokens.c_nonce),
320
+ claims: { given_name: "Alice" },
321
+ });
322
+ return { rv: rv, captured: captured };
323
+ }
324
+
325
+ async function testOid4vciKidResolver() {
326
+ var holderKp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
327
+ var holderPubJwk = holderKp.publicKey.export({ format: "jwk" });
328
+ var aud = "https://issuer.example";
329
+
330
+ // Config-time: a non-function resolveKid is refused at create().
331
+ var threw = false;
332
+ try {
333
+ b.auth.oid4vci.issuer.create({
334
+ credentialIssuerUrl: aud, credentialEndpoint: aud + "/credential", tokenEndpoint: aud + "/token",
335
+ sdJwtIssuer: { issue: async function () { return { token: "x" }; } },
336
+ supportedCredentials: { "id-card-1": { format: "vc+sd-jwt", vct: "https://x" } },
337
+ resolveKid: "not-a-function",
338
+ });
339
+ } catch (e) { threw = /resolveKid.*must be a function/.test(e.message); }
340
+ check("OID4VCI: non-function resolveKid refused at config time", threw);
341
+
342
+ // kid-only proof with a matching resolveKid → verifies + binds cnf.
343
+ var resolved = await _runIssuanceWithProof(
344
+ { resolveKid: function (kid) { return kid === "holder-key-1" ? holderPubJwk : null; } },
345
+ function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
346
+ check("OID4VCI: kid-only proof with resolveKid issues credential",
347
+ resolved.rv && resolved.rv.credential === "fake-sd-jwt");
348
+ check("OID4VCI: resolved holder key bound to cnf",
349
+ resolved.captured.holderKey && resolved.captured.holderKey.x === holderPubJwk.x);
350
+
351
+ // resolveKid may return a node:crypto KeyObject (exported to JWK for cnf).
352
+ var resolvedKo = await _runIssuanceWithProof(
353
+ { resolveKid: function () { return holderKp.publicKey; } },
354
+ function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
355
+ check("OID4VCI: resolveKid KeyObject return verifies + exports cnf JWK",
356
+ resolvedKo.captured.holderKey && resolvedKo.captured.holderKey.kty === "EC");
357
+
358
+ // kid-only proof with NO resolveKid configured → clear refusal (back-compat).
359
+ threw = false;
360
+ try {
361
+ await _runIssuanceWithProof(
362
+ {},
363
+ function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
364
+ } catch (e) { threw = /kid-resolver-not-supported/.test(e.code) || /resolveKid/.test(e.message); }
365
+ check("OID4VCI: kid-only proof without resolveKid still refused", threw);
366
+
367
+ // resolveKid returns a WRONG key (different holder) → signature fails cleanly.
368
+ var otherKp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
369
+ threw = false;
370
+ try {
371
+ await _runIssuanceWithProof(
372
+ { resolveKid: function () { return otherKp.publicKey.export({ format: "jwk" }); } },
373
+ function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
374
+ } catch (e) { threw = /proof-bad-signature|signature verification failed/.test(e.message); }
375
+ check("OID4VCI: resolveKid wrong key fails signature verification", threw);
376
+
377
+ // resolveKid returns garbage (no key) → clean refusal, no crash.
378
+ threw = false;
379
+ try {
380
+ await _runIssuanceWithProof(
381
+ { resolveKid: function () { return null; } },
382
+ function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
383
+ } catch (e) { threw = /kid-unresolved/.test(e.code) || /returned no key/.test(e.message); }
384
+ check("OID4VCI: resolveKid returning no key refused cleanly", threw);
385
+
386
+ // resolveKid that THROWS → wrapped as a stable AuthError code, not an
387
+ // unhandled rejection. The resolver is operator code, so its own
388
+ // message is allowed through for operator debugging.
389
+ threw = false;
390
+ var isAuthErr = false;
391
+ try {
392
+ await _runIssuanceWithProof(
393
+ { resolveKid: function () { throw new Error("resolver-backend-down"); } },
394
+ function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
395
+ } catch (e) {
396
+ threw = /kid-resolver-failed/.test(e.code) || /resolveKid threw/.test(e.message);
397
+ isAuthErr = e instanceof b.frameworkError.AuthError;
398
+ }
399
+ check("OID4VCI: resolveKid throwing yields kid-resolver-failed", threw);
400
+ check("OID4VCI: resolveKid failure is a typed AuthError", isAuthErr);
401
+ }
402
+
284
403
  // ---- CIBA client config ----------------------------------------------
285
404
 
286
405
  function testCibaConfig() {
@@ -440,6 +559,7 @@ async function run() {
440
559
  testFederationParseAndPolicy();
441
560
  testDcqlMatch();
442
561
  testOid4vciIssuerConfig();
562
+ await testOid4vciKidResolver();
443
563
  testCibaConfig();
444
564
  await testSdJwtKeyAttestationStorage();
445
565
  await testAnonymousSessions();
@@ -93,6 +93,19 @@ function testGuardEmailMixedScript() {
93
93
  rv.issues.some(function (i) { return i.kind === "mixed-script-domain"; }));
94
94
  }
95
95
 
96
+ function testGuardEmailUnicodeLocalPartRejected() {
97
+ // Unicode local-part (RFC 6531 SMTPUTF8 / EAI) is NOT accepted — the
98
+ // local-part is ASCII atext only (RFC 5321 §4.1.2 / RFC 5322 §3.2.3).
99
+ // Build the accented codepoint programmatically so the source stays
100
+ // pure ASCII (no attack characters as literals). U+00E9 = "e acute".
101
+ var local = "u" + String.fromCodePoint(0x00e9) + "ser";
102
+ var rv = b.guardEmail.validateAddress(local + "@example.com", { profile: "strict" });
103
+ check("unicode local-part not accepted (no RFC 6531 EAI mailbox)",
104
+ rv.ok === false);
105
+ check("unicode local-part surfaces as address-syntax (ASCII-only contract)",
106
+ rv.issues.some(function (i) { return i.kind === "address-syntax"; }));
107
+ }
108
+
96
109
  function testGuardEmailSyntaxReject() {
97
110
  var rv = b.guardEmail.validateAddress("not an email", { profile: "strict" });
98
111
  check("malformed address → multi-at or address-syntax issue",
@@ -218,6 +231,7 @@ async function run() {
218
231
  testGuardEmailAddressComment();
219
232
  testGuardEmailPunycode();
220
233
  testGuardEmailMixedScript();
234
+ testGuardEmailUnicodeLocalPartRejected();
221
235
  testGuardEmailSyntaxReject();
222
236
  testGuardEmailBareLfSmuggling();
223
237
  testGuardEmailCrlfHeaderInjection();
@@ -164,6 +164,58 @@ async function testDownloadHttpError() {
164
164
  });
165
165
  }
166
166
 
167
+ async function testStreamErrorBodyPreserved() {
168
+ // A streamed (responseMode "stream") non-2xx must surface a bounded prefix
169
+ // of the error body on the thrown error instead of draining it to nothing,
170
+ // so the caller can read the problem+json / encrypted error detail.
171
+ await _withServer(function (req, res) {
172
+ res.writeHead(403, { "Content-Type": "application/json" });
173
+ res.end(JSON.stringify({ error: "forbidden", detail: "stream-error-detail" }));
174
+ }, async function (baseUrl) {
175
+ var thrown = null;
176
+ try {
177
+ await b.httpClient.request({
178
+ url: baseUrl + "/x",
179
+ method: "GET",
180
+ responseMode: "stream",
181
+ allowedProtocols: b.safeUrl.ALLOW_HTTP_ALL,
182
+ allowInternal: true,
183
+ });
184
+ } catch (e) { thrown = e; }
185
+ check("stream non-2xx: threw HTTP_ERROR", thrown != null && thrown.code === "HTTP_ERROR");
186
+ check("stream non-2xx: message names the status", thrown && /403/.test(thrown.message));
187
+ check("stream non-2xx: err.body is a Buffer", thrown && Buffer.isBuffer(thrown.body));
188
+ check("stream non-2xx: err.body carries detail",
189
+ thrown && thrown.body && /stream-error-detail/.test(thrown.body.toString("utf8")));
190
+ });
191
+
192
+ // A large (> cap) error body must reject promptly with a bounded prefix —
193
+ // it must NOT leave the request promise pending until the whole body
194
+ // drains to close (if it did, this test would hang past the file timeout).
195
+ var BODY_LEN = 50003;
196
+ await _withServer(function (req, res) {
197
+ res.writeHead(500, { "Content-Type": "text/plain" });
198
+ res.end("E".repeat(BODY_LEN) + "TAIL-MARKER");
199
+ }, async function (baseUrl) {
200
+ var thrown = null;
201
+ try {
202
+ await b.httpClient.request({
203
+ url: baseUrl + "/big",
204
+ method: "GET",
205
+ responseMode: "stream",
206
+ allowedProtocols: b.safeUrl.ALLOW_HTTP_ALL,
207
+ allowInternal: true,
208
+ });
209
+ } catch (e) { thrown = e; }
210
+ check("stream large-error: threw HTTP_ERROR (did not hang)",
211
+ thrown != null && thrown.code === "HTTP_ERROR");
212
+ check("stream large-error: body bounded well below sent size",
213
+ thrown && thrown.body && thrown.body.length > 0 && thrown.body.length < BODY_LEN);
214
+ check("stream large-error: tail beyond cap was dropped",
215
+ thrown && thrown.body && thrown.body.toString("utf8").indexOf("TAIL-MARKER") === -1);
216
+ });
217
+ }
218
+
167
219
  async function testDownloadBadOpts() {
168
220
  var thrown = null;
169
221
  try {
@@ -266,6 +318,7 @@ async function run() {
266
318
  await testDownloadExpectedHashMatch();
267
319
  await testDownloadHashMismatch();
268
320
  await testDownloadHttpError();
321
+ await testStreamErrorBodyPreserved();
269
322
  await testDownloadBadOpts();
270
323
  await testUploadHappyPath();
271
324
  await testUploadMissingFile();
@@ -682,6 +682,90 @@ function testDmarcRuaGunzipBombDistinguished() {
682
682
  threw && /dmarc-rua-gunzip-bomb/.test(threw.code || ""));
683
683
  }
684
684
 
685
+ function testDmarcRuaBuildRoundTrip() {
686
+ // RFC 7489 Appendix C — buildAggregateReport is the inverse of
687
+ // parseAggregateReport; a shaped report serialized to XML and re-
688
+ // parsed MUST deep-equal the input (the parser adds a derived
689
+ // `totals` convenience field, removed before comparison).
690
+ var shaped = {
691
+ reportMetadata: {
692
+ orgName: "reporter.example",
693
+ email: "dmarc@reporter.example",
694
+ reportId: "rpt-12345",
695
+ extraContact: null,
696
+ dateRange: { begin: 1700000000, end: 1700086400 },
697
+ },
698
+ policyPublished: {
699
+ domain: "example.com", adkim: "r", aspf: "r",
700
+ p: "reject", sp: "quarantine", pct: 100, fo: null,
701
+ },
702
+ records: [
703
+ {
704
+ sourceIp: "192.0.2.10", count: 7,
705
+ dispositions: {
706
+ disposition: "none", dkim: "pass", spf: "fail",
707
+ reasons: [{ type: "forwarded", comment: "mailing list" }],
708
+ },
709
+ identifiers: { headerFrom: "example.com", envelopeFrom: "example.com", envelopeTo: null },
710
+ authResults: {
711
+ dkim: [{ domain: "example.com", selector: "sel1", result: "pass", humanResult: null }],
712
+ spf: [{ domain: "example.com", result: "fail", scope: "mfrom" }],
713
+ },
714
+ },
715
+ {
716
+ // header_from carries XML metacharacters (a spoofed identifier
717
+ // observed in the wild) — escaping MUST survive the round-trip.
718
+ sourceIp: "203.0.113.5", count: 3,
719
+ dispositions: { disposition: "quarantine", dkim: "fail", spf: "fail", reasons: [] },
720
+ identifiers: { headerFrom: "evil<&>\".example.com", envelopeFrom: null, envelopeTo: null },
721
+ authResults: { dkim: [], spf: [{ domain: "spoof.example", result: "fail", scope: "mfrom" }] },
722
+ },
723
+ ],
724
+ };
725
+ var xml = b.mail.dmarc.buildAggregateReport(shaped);
726
+ check("dmarc.buildAggregateReport returns an XML string with <feedback> root",
727
+ typeof xml === "string" && /^<\?xml/.test(xml) && xml.indexOf("<feedback>") !== -1);
728
+ // The injected metacharacters must be entity-escaped in the wire form.
729
+ check("dmarc.buildAggregateReport escapes XML metacharacters",
730
+ xml.indexOf("evil<&>") === -1 && xml.indexOf("&lt;&amp;&gt;") !== -1);
731
+
732
+ var back = b.mail.dmarc.parseAggregateReport(xml);
733
+ delete back.totals;
734
+ check("dmarc.buildAggregateReport round-trips through parseAggregateReport",
735
+ JSON.stringify(back) === JSON.stringify(shaped));
736
+
737
+ // Optional gzip path mirrors the parser's gzip auto-detect.
738
+ var gz = b.mail.dmarc.buildAggregateReport(shaped, { gzip: true });
739
+ check("dmarc.buildAggregateReport({gzip:true}) → gzip Buffer",
740
+ Buffer.isBuffer(gz) && gz[0] === 0x1f && gz[1] === 0x8b);
741
+ var back2 = b.mail.dmarc.parseAggregateReport(gz, { contentType: "application/gzip" });
742
+ delete back2.totals;
743
+ check("dmarc.buildAggregateReport(gzip) round-trips",
744
+ JSON.stringify(back2) === JSON.stringify(shaped));
745
+ }
746
+
747
+ function testDmarcRuaBuildBadInput() {
748
+ // Config-time tier — a malformed report shape THROWS so the operator
749
+ // catches it before mailing the report to a peer.
750
+ var cases = [
751
+ { arg: null, re: /object/ },
752
+ { arg: { policyPublished: {}, records: [] }, re: /reportMetadata/ },
753
+ { arg: { reportMetadata: {}, records: [] }, re: /policyPublished/ },
754
+ { arg: { reportMetadata: {}, policyPublished: {} }, re: /records/ },
755
+ { arg: { reportMetadata: {}, policyPublished: {},
756
+ records: [{ sourceIp: "1.2.3.4", count: "notnum",
757
+ dispositions: {}, identifiers: {}, authResults: {} }] },
758
+ re: /finite integer/ },
759
+ ];
760
+ for (var i = 0; i < cases.length; i += 1) {
761
+ var threw = null;
762
+ try { b.mail.dmarc.buildAggregateReport(cases[i].arg); }
763
+ catch (e) { threw = e; }
764
+ check("dmarc.buildAggregateReport rejects bad input #" + i,
765
+ threw && cases[i].re.test(threw.message || ""));
766
+ }
767
+ }
768
+
685
769
  async function testIprevValidatesPtrShape() {
686
770
  // MAIL-50 — PTR result MUST be a valid DNS-name shape (LDH labels,
687
771
  // 1..63 octets, total 1..253 octets). Synthesize via a mocked
@@ -796,19 +880,104 @@ async function testSpfMechanismMxOverLimit() {
796
880
  rv.result === "permerror" && /caps at 10/.test(rv.explanation || ""));
797
881
  }
798
882
 
799
- async function testSpfMechanismExistsRemainsDeferred() {
883
+ async function testSpfMechanismExists() {
884
+ // RFC 7208 §5.7 + §7 — `exists:<macro-string>` resolves the expanded
885
+ // domain; a present A record matches. The macro `%{l}` expands to the
886
+ // sender local-part, so `exists:%{l}.spf.example.com` queries
887
+ // `alice.spf.example.com`.
888
+ var queried = [];
800
889
  var dnsLookup = async function (host, type) {
801
890
  if (host === "example.com" && type === "TXT") {
802
891
  return [["v=spf1 exists:%{l}.spf.example.com -all"]];
803
892
  }
893
+ if (type === "A") {
894
+ queried.push(host);
895
+ if (host === "alice.spf.example.com") return ["127.0.0.2"];
896
+ }
804
897
  var err = new Error("ENOTFOUND"); err.code = "ENOTFOUND"; throw err;
805
898
  };
806
899
  var rv = await b.mail.spf.verify({
807
900
  ip: "192.0.2.5", mailFrom: "alice@example.com", dnsLookup: dnsLookup,
808
901
  });
809
- // exists deferred surface as permerror with explicit explanation.
810
- check("spf.verify(exists, deferred) permerror with §5.7 cite",
811
- rv.result === "permerror" && /5\.7/.test(rv.explanation || ""));
902
+ check("spf.verify(exists:%{l}..., A present) -> pass + expanded qname",
903
+ rv.result === "pass" && queried.some(function (q) { return q === "alice.spf.example.com"; }));
904
+
905
+ // No A record at the expanded target -> exists misses -> falls to -all.
906
+ var dnsMiss = async function (host, type) {
907
+ if (host === "example.com" && type === "TXT") {
908
+ return [["v=spf1 exists:%{l}.spf.example.com -all"]];
909
+ }
910
+ var err = new Error("ENOTFOUND"); err.code = "ENOTFOUND"; throw err;
911
+ };
912
+ var rv2 = await b.mail.spf.verify({
913
+ ip: "192.0.2.5", mailFrom: "alice@example.com", dnsLookup: dnsMiss,
914
+ });
915
+ check("spf.verify(exists, no A at target) -> fail (-all)", rv2.result === "fail");
916
+ }
917
+
918
+ async function testSpfMacroExpansionExistsIrV() {
919
+ // RFC 7208 §7.1-§7.4 — `%{ir}.%{v}._spf.%{d}` is the canonical per-IP
920
+ // exists query. For 192.0.2.10: %{ir} reverses the dotted IP to
921
+ // "10.2.0.192", %{v} -> "in-addr", %{d} -> the current domain.
922
+ // Expected qname: 10.2.0.192.in-addr._spf.example.com.
923
+ var queried = null;
924
+ var dnsLookup = async function (host, type) {
925
+ if (host === "example.com" && type === "TXT") {
926
+ return [["v=spf1 exists:%{ir}.%{v}._spf.%{d} -all"]];
927
+ }
928
+ if (type === "A") {
929
+ queried = host;
930
+ if (host === "10.2.0.192.in-addr._spf.example.com") return ["127.0.0.2"];
931
+ }
932
+ var err = new Error("ENOTFOUND"); err.code = "ENOTFOUND"; throw err;
933
+ };
934
+ var rv = await b.mail.spf.verify({
935
+ ip: "192.0.2.10", mailFrom: "alice@example.com",
936
+ helo: "mta.example.com", dnsLookup: dnsLookup,
937
+ });
938
+ check("spf macro %{ir}.%{v}._spf.%{d} expands correctly",
939
+ queried === "10.2.0.192.in-addr._spf.example.com" && rv.result === "pass");
940
+ }
941
+
942
+ async function testSpfMacroBadSyntaxPermerror() {
943
+ // RFC 7208 §7.1 — malformed macro syntax MUST permerror.
944
+ var shapes = [
945
+ { rec: "v=spf1 exists:%{z} -all", re: /7\.2/ }, // unknown letter
946
+ { rec: "v=spf1 exists:%{i.x -all", re: /closing/ }, // no closing brace
947
+ { rec: "v=spf1 exists:%q -all", re: /invalid/ }, // bad escape
948
+ { rec: "v=spf1 exists:%{} -all", re: /empty/ }, // empty macro
949
+ ];
950
+ for (var i = 0; i < shapes.length; i += 1) {
951
+ var rec = shapes[i].rec;
952
+ var dns = (function (r) {
953
+ return async function (host, type) {
954
+ if (host === "example.com" && type === "TXT") return [[r]];
955
+ var err = new Error("ENOTFOUND"); err.code = "ENOTFOUND"; throw err;
956
+ };
957
+ }(rec));
958
+ var rv = await b.mail.spf.verify({
959
+ ip: "192.0.2.1", mailFrom: "bob@example.com", dnsLookup: dns,
960
+ });
961
+ check("spf.verify(" + JSON.stringify(rec) + ") -> permerror (bad macro)",
962
+ rv.result === "permerror" && shapes[i].re.test(rv.explanation || ""));
963
+ }
964
+ }
965
+
966
+ async function testSpfExistsRespectsLookupLimit() {
967
+ // RFC 7208 §4.6.4 — exists A queries count toward the 10-lookup and
968
+ // 2-void ceilings; a chain of exists: misses MUST permerror rather
969
+ // than amplifying resolver work without bound.
970
+ var dns = async function (host, type) {
971
+ if (type === "TXT" && host === "example.com") {
972
+ return [["v=spf1 exists:a.%{d} exists:b.%{d} exists:c.%{d} -all"]];
973
+ }
974
+ var err = new Error("ENODATA"); err.code = "ENODATA"; throw err;
975
+ };
976
+ var rv = await b.mail.spf.verify({
977
+ ip: "192.0.2.1", mailFrom: "bob@example.com", dnsLookup: dns,
978
+ });
979
+ check("spf.verify(exists chain) -> permerror (RFC 7208 §4.6.4 void cap)",
980
+ rv.result === "permerror" && /void-lookup limit/.test(rv.explanation || ""));
812
981
  }
813
982
 
814
983
  async function testSpfMechanismEmptyDualCidrRefused() {
@@ -860,7 +1029,10 @@ async function run() {
860
1029
  await testSpfMechanismADualCidr();
861
1030
  await testSpfMechanismMx();
862
1031
  await testSpfMechanismMxOverLimit();
863
- await testSpfMechanismExistsRemainsDeferred();
1032
+ await testSpfMechanismExists();
1033
+ await testSpfMacroExpansionExistsIrV();
1034
+ await testSpfMacroBadSyntaxPermerror();
1035
+ await testSpfExistsRespectsLookupLimit();
864
1036
  await testSpfMechanismEmptyDualCidrRefused();
865
1037
  await testSpfMechanismPtrRemainsDeferred();
866
1038
  testDmarcParse();
@@ -898,6 +1070,8 @@ async function run() {
898
1070
  await testDmarcPctSamplingDeterministic();
899
1071
  await testDmarcAlignmentUsesPsl();
900
1072
  testDmarcRuaGunzipBombDistinguished();
1073
+ testDmarcRuaBuildRoundTrip();
1074
+ testDmarcRuaBuildBadInput();
901
1075
  await testIprevValidatesPtrShape();
902
1076
  await testArcHeaderSourceOrder();
903
1077
  }
@@ -60,6 +60,50 @@ function testFactoryRefusesBadOpts() {
60
60
  });
61
61
  check("dsn without onPermanentFailure → DeliverError",
62
62
  e5 && e5.code === "deliver/bad-dsn-callback");
63
+
64
+ var e6 = threw(function () {
65
+ b.mail.send.deliver({ hostname: "m.example", port: 70000 }); // out of [1,65535]
66
+ });
67
+ check("out-of-range port → DeliverError",
68
+ e6 && e6.code === "deliver/bad-port");
69
+
70
+ var e7 = threw(function () {
71
+ b.mail.send.deliver({ hostname: "m.example", port: 0 }); // 0 is not a connect port
72
+ });
73
+ check("port 0 → DeliverError (connect port must be >=1)",
74
+ e7 && e7.code === "deliver/bad-port");
75
+ }
76
+
77
+ // ---- Submission/smarthost port ----
78
+
79
+ // The default is IANA SMTP 25; an operator routing through a submission
80
+ // relay sets port 587 (RFC 6409) / 465 (RFC 8314). The configured port
81
+ // must reach the transport factory.
82
+ async function testPortReachesTransport() {
83
+ var ports = [];
84
+ var fakeResolver = {
85
+ queryMx: async function (domain) {
86
+ return [{ exchange: "mx1." + domain, priority: 10 }];
87
+ },
88
+ };
89
+ var fakeTransport = function (opts) {
90
+ ports.push(opts.port);
91
+ return { send: async function () { return { ok: true, code: 250 }; } };
92
+ };
93
+
94
+ var deliverDefault = b.mail.send.deliver({
95
+ hostname: "mta1.example.com", resolver: fakeResolver,
96
+ policy: { mtaSts: "off", dane: "off" }, transportFactory: fakeTransport, audit: false,
97
+ });
98
+ await deliverDefault({ from: "ops@example.com", to: ["a@recipient.com"], rfc822: Buffer.from("hi") });
99
+ check("port: default is 25 when unset", ports[ports.length - 1] === 25);
100
+
101
+ var deliver587 = b.mail.send.deliver({
102
+ hostname: "mta1.example.com", resolver: fakeResolver, port: 587,
103
+ policy: { mtaSts: "off", dane: "off" }, transportFactory: fakeTransport, audit: false,
104
+ });
105
+ await deliver587({ from: "ops@example.com", to: ["b@recipient.com"], rfc822: Buffer.from("hi") });
106
+ check("port: configured 587 reaches the transport", ports[ports.length - 1] === 587);
63
107
  }
64
108
 
65
109
  // ---- Envelope shape validation ----
@@ -324,6 +368,7 @@ async function run() {
324
368
  await testTransientDefersPermanentFails();
325
369
  await testNullMx();
326
370
  await testMxFailover();
371
+ await testPortReachesTransport();
327
372
  }
328
373
 
329
374
  module.exports = { run: run };
@@ -4,12 +4,61 @@
4
4
  * - parseCallback (RFC 9207 AS Issuer Identifier validation)
5
5
  * - parseJarmResponse (OAuth 2.0 JARM signed authorization response)
6
6
  * - refreshAccessToken seen() callback (RFC 9700 §4.13 / OAuth 2.1 §6.1)
7
+ * - authorizationUrl PKCE-downgrade refusal (RFC 9700 §4.13 / RFC 7636)
7
8
  */
8
9
 
10
+ var http = require("node:http");
9
11
  var helpers = require("../helpers");
10
12
  var b = helpers.b;
11
13
  var check = helpers.check;
12
14
 
15
+ // Minimal OIDC discovery server: serves /.well-known/openid-configuration
16
+ // with operator-chosen code_challenge_methods_supported so the PKCE
17
+ // downgrade gate runs against a real discovery round-trip.
18
+ function _spawnDiscoveryServer(methods) {
19
+ var issuerHolder = { value: null };
20
+ var server = http.createServer(function (req, res) {
21
+ var u = new URL(req.url, "http://localhost");
22
+ if (u.pathname !== "/.well-known/openid-configuration") { res.writeHead(404); res.end(); return; }
23
+ var doc = {
24
+ issuer: issuerHolder.value,
25
+ authorization_endpoint: issuerHolder.value + "/auth",
26
+ token_endpoint: issuerHolder.value + "/token",
27
+ jwks_uri: issuerHolder.value + "/jwks",
28
+ };
29
+ if (methods !== undefined) doc.code_challenge_methods_supported = methods;
30
+ var body = JSON.stringify(doc);
31
+ res.writeHead(200, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
32
+ res.end(body);
33
+ });
34
+ server._issuerHolder = issuerHolder;
35
+ return server;
36
+ }
37
+
38
+ async function _pkceDowngradeCase(methods, expectRefusal, label) {
39
+ var server = _spawnDiscoveryServer(methods);
40
+ await new Promise(function (r) { server.listen(0, "127.0.0.1", r); });
41
+ var issuer = "http://127.0.0.1:" + server.address().port;
42
+ server._issuerHolder.value = issuer;
43
+ try {
44
+ var oa = b.auth.oauth.create({
45
+ issuer: issuer,
46
+ clientId: "rp-dl",
47
+ redirectUri: "https://rp.example/cb",
48
+ isOidc: true,
49
+ allowHttp: true,
50
+ allowInternal: true,
51
+ });
52
+ var err = null;
53
+ try { await oa.authorizationUrl(); } catch (e) { err = e; }
54
+ if (expectRefusal) {
55
+ check(label, err !== null && err.code === "auth-oauth/pkce-downgrade");
56
+ } else {
57
+ check(label, err === null);
58
+ }
59
+ } finally { server.close(); }
60
+ }
61
+
13
62
  async function run() {
14
63
  var oauth = b.auth.oauth.create({
15
64
  issuer: "https://idp.example",
@@ -60,6 +109,37 @@ async function run() {
60
109
  await oauth.refreshAccessToken("rt-1", { seen: async function () { return true; } });
61
110
  } catch (e) { threw = /refresh-token-replay/.test(e.code); }
62
111
  check("oauth.refreshAccessToken: seen()=true refuses replay", threw);
112
+
113
+ // PKCE downgrade defense (RFC 9700 §4.13 / RFC 7636). An OP whose
114
+ // discovery metadata advertises code_challenge_methods_supported
115
+ // without "S256" is refused at authorizationUrl; S256-capable or
116
+ // field-absent OPs keep working (back-compat).
117
+ await _pkceDowngradeCase(["plain"], true,
118
+ "oauth.authorizationUrl: plain-only OP refused (PKCE downgrade)");
119
+ await _pkceDowngradeCase([], true,
120
+ "oauth.authorizationUrl: empty methods list refused (no S256)");
121
+ await _pkceDowngradeCase(["S256"], false,
122
+ "oauth.authorizationUrl: S256-only OP accepted");
123
+ await _pkceDowngradeCase(["S256", "plain"], false,
124
+ "oauth.authorizationUrl: S256+plain OP accepted");
125
+ await _pkceDowngradeCase(undefined, false,
126
+ "oauth.authorizationUrl: field-absent OP accepted (back-compat)");
127
+
128
+ // Static-endpoint client with no discovery must never gain a network
129
+ // fetch or a downgrade refusal — the gate only inspects already-
130
+ // resolved discovery metadata.
131
+ var oaStatic = b.auth.oauth.create({
132
+ issuer: "https://static.example",
133
+ clientId: "rp-static",
134
+ redirectUri: "https://rp.example/cb",
135
+ isOidc: true,
136
+ authorizationEndpoint: "https://static.example/auth",
137
+ tokenEndpoint: "https://static.example/token",
138
+ });
139
+ var staticErr = null;
140
+ try { await oaStatic.authorizationUrl(); } catch (e) { staticErr = e; }
141
+ check("oauth.authorizationUrl: static endpoints skip discovery (no fetch, no refusal)",
142
+ staticErr === null);
63
143
  }
64
144
 
65
145
  module.exports = { run: run };