@blamejs/blamejs-shop 0.2.2 → 0.2.4
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.
- package/CHANGELOG.md +4 -0
- package/lib/admin.js +171 -5
- package/lib/asset-manifest.json +9 -5
- package/lib/storefront.js +193 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/SECURITY.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +54 -58
- package/lib/vendor/blamejs/lib/acme.js +8 -5
- package/lib/vendor/blamejs/lib/archive-read.js +24 -20
- package/lib/vendor/blamejs/lib/break-glass.js +4 -5
- package/lib/vendor/blamejs/lib/crypto.js +8 -5
- package/lib/vendor/blamejs/lib/guard-dsn.js +3 -2
- package/lib/vendor/blamejs/lib/guard-list-id.js +3 -2
- package/lib/vendor/blamejs/lib/guard-regex.js +4 -4
- package/lib/vendor/blamejs/lib/guard-smtp-command.js +1 -2
- package/lib/vendor/blamejs/lib/mail-agent.js +47 -37
- package/lib/vendor/blamejs/lib/network-dnssec.js +98 -5
- package/lib/vendor/blamejs/lib/parsers/safe-toml.js +8 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.14.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.15.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.16.json +27 -0
- package/lib/vendor/blamejs/test/00-primitives.js +9 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +4 -8
- package/lib/vendor/blamejs/test/layer-0-primitives/dnssec.test.js +94 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-agent.test.js +1 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/watcher.test.js +6 -0
- package/package.json +1 -1
|
@@ -101,7 +101,16 @@ function _canonicalName(name) {
|
|
|
101
101
|
parts.push(Buffer.from([lab.length]), lab);
|
|
102
102
|
}
|
|
103
103
|
parts.push(Buffer.from([0]));
|
|
104
|
-
|
|
104
|
+
var wire = Buffer.concat(parts);
|
|
105
|
+
// RFC 1035 §2.3.4 — a domain name is at most 255 octets on the wire.
|
|
106
|
+
// Enforcing it here also bounds the per-label count (and thus the NSEC3
|
|
107
|
+
// closest-encloser candidate enumeration, CVE-2023-50868 class), since
|
|
108
|
+
// each label costs at least 2 octets.
|
|
109
|
+
if (wire.length > 255) { // allow:raw-byte-literal — RFC 1035 total-name octet cap
|
|
110
|
+
throw new DnssecError("dnssec/bad-name",
|
|
111
|
+
"dnssec: name '" + name + "' encodes to " + wire.length + " octets, exceeds RFC 1035 cap of 255");
|
|
112
|
+
}
|
|
113
|
+
return wire;
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
function _u16(n) { return Buffer.from([(n >> 8) & 0xff, n & 0xff]); } // allow:raw-byte-literal — 16-bit big-endian split
|
|
@@ -340,7 +349,28 @@ var BASE32HEX = "0123456789ABCDEFGHIJKLMNOPQRSTUV"; // allow:raw-byte-l
|
|
|
340
349
|
var TYPE_DS = 43; // allow:raw-byte-literal — IANA RR type DS
|
|
341
350
|
var TYPE_CNAME = 5;
|
|
342
351
|
var NSEC3_HASH_SHA1 = 1; // RFC 5155 §5 — the only registered NSEC3 hash
|
|
343
|
-
var DEFAULT_MAX_NSEC3_ITERATIONS =
|
|
352
|
+
var DEFAULT_MAX_NSEC3_ITERATIONS = 150; // allow:raw-time-literal — DoS ceiling on iterated SHA-1; matches BIND 9.16.33+/Unbound 1.17.1 post-CVE-2023-50868 (RFC 9276 wants 0; deployed zones still use >0)
|
|
353
|
+
|
|
354
|
+
// KeyTrap (CVE-2023-50387) amplification caps. A hostile zone can publish
|
|
355
|
+
// many DNSKEYs sharing one 16-bit key tag and many RRSIGs, forcing a
|
|
356
|
+
// validator into O(keys x sigs) full signature verifications from a single
|
|
357
|
+
// query. Bound both factors: the colliding-candidate fan-out per RRSIG, and
|
|
358
|
+
// the total signature-validation work. Matches the BIND
|
|
359
|
+
// `max-key-tag-collisions` + Unbound validation-budget mitigations.
|
|
360
|
+
//
|
|
361
|
+
// The per-response budget SCALES with declared chain depth so a legitimate
|
|
362
|
+
// deep delegation isn't false-rejected: a valid N-link chain does ~2N-1
|
|
363
|
+
// signature verifies (1 root DNSKEY + parent-DS + child-DNSKEY per child),
|
|
364
|
+
// so the budget is links.length * MAX_VALIDATIONS_PER_LINK (= 2 RRSIGs/link
|
|
365
|
+
// x MAX_COLLIDING_KEYS candidates), which always covers the legitimate work
|
|
366
|
+
// while still bounding the bounded-collision amplification. Chain length
|
|
367
|
+
// itself is capped (a delegation can't be deeper than a DNS name's label
|
|
368
|
+
// count, RFC 1035), so the scaled budget can't be inflated arbitrarily.
|
|
369
|
+
var MAX_COLLIDING_KEYS = 4; // allow:raw-byte-literal — same-tag DNSKEY candidates tried per RRSIG
|
|
370
|
+
var MAX_VALIDATIONS_PER_LINK = 8; // allow:raw-byte-literal — 2 RRSIGs/link x MAX_COLLIDING_KEYS; budget = links.length x this
|
|
371
|
+
var MAX_CHAIN_LINKS = 128; // allow:raw-byte-literal — max delegation depth (>= RFC 1035 max label count)
|
|
372
|
+
var MAX_DNSKEYS_PER_ZONE = 64; // allow:raw-byte-literal — DNSKEY RRset size cap per zone link
|
|
373
|
+
var MAX_DS_RECORDS = 16; // allow:raw-byte-literal — DS RRset size cap (parent-supplied)
|
|
344
374
|
|
|
345
375
|
// RFC 4648 §7 base32hex decode (no padding, case-insensitive) — the
|
|
346
376
|
// label encoding of an NSEC3 owner-name hash.
|
|
@@ -757,12 +787,31 @@ function _keysByTag(dnskeys, tag) {
|
|
|
757
787
|
// returning the key that validated. A wrong colliding key yields
|
|
758
788
|
// `dnssec/bad-signature` — that is not terminal, the next candidate is
|
|
759
789
|
// tried; any other error (expired, alg) is terminal. RFC 4035 §5.3.1.
|
|
760
|
-
function _verifyRrsetWithAnyKey(rrsetBase, rrsig, candidates, noKeyCode, noKeyMsg) {
|
|
790
|
+
function _verifyRrsetWithAnyKey(rrsetBase, rrsig, candidates, noKeyCode, noKeyMsg, budget) {
|
|
761
791
|
if (candidates.length === 0) throw new DnssecError(noKeyCode, noKeyMsg);
|
|
792
|
+
// KeyTrap (CVE-2023-50387): refuse an absurd same-tag fan-out outright —
|
|
793
|
+
// legitimate zones have 1-2 keys per tag; hundreds is an amplification
|
|
794
|
+
// attack, not a real collision.
|
|
795
|
+
if (candidates.length > MAX_COLLIDING_KEYS) {
|
|
796
|
+
throw new DnssecError("dnssec/too-many-colliding-keys",
|
|
797
|
+
"dnssec.verifyChain: " + candidates.length + " DNSKEYs share key tag " +
|
|
798
|
+
rrsig.keyTag + " (cap " + MAX_COLLIDING_KEYS +
|
|
799
|
+
") — refused as a KeyTrap (CVE-2023-50387) amplification vector");
|
|
800
|
+
}
|
|
762
801
|
var lastErr = null;
|
|
763
802
|
for (var i = 0; i < candidates.length; i++) {
|
|
764
803
|
var kp = _dnskeyParts(candidates[i]);
|
|
765
804
|
if (kp.algorithm !== rrsig.algorithm) { lastErr = new DnssecError("dnssec/alg-mismatch", "dnssec.verifyChain: candidate key algorithm does not match the RRSIG"); continue; }
|
|
805
|
+
// Per-response signature-validation budget — bound the total expensive
|
|
806
|
+
// pubkey verifies across the whole chain walk, not just per RRSIG.
|
|
807
|
+
if (budget) {
|
|
808
|
+
if (budget.remaining <= 0) {
|
|
809
|
+
throw new DnssecError("dnssec/validation-budget-exceeded",
|
|
810
|
+
"dnssec.verifyChain: per-response signature-validation budget " +
|
|
811
|
+
"exhausted — refused as a KeyTrap (CVE-2023-50387) amplification vector");
|
|
812
|
+
}
|
|
813
|
+
budget.remaining -= 1;
|
|
814
|
+
}
|
|
766
815
|
try {
|
|
767
816
|
verifyRrset(Object.assign({}, rrsetBase, { rrsig: rrsig, dnskey: { algorithm: kp.algorithm, publicKey: kp.publicKey } }));
|
|
768
817
|
return candidates[i];
|
|
@@ -795,6 +844,20 @@ function _verifyRrsetWithAnyKey(rrsetBase, rrsig, candidates, noKeyCode, noKeyMs
|
|
|
795
844
|
* passes to <code>verifyRrset</code> / <code>verifyDenial</code> for the
|
|
796
845
|
* actual answer.
|
|
797
846
|
*
|
|
847
|
+
* KeyTrap (CVE-2023-50387) amplification is bounded with non-configurable
|
|
848
|
+
* caps: at most 4 same-tag DNSKEY candidates are tried per RRSIG, at most
|
|
849
|
+
* 64 DNSKEYs per zone link and 16 DS records per delegation are accepted,
|
|
850
|
+
* the chain is at most 128 links deep, and the whole response is held to a
|
|
851
|
+
* signature-validation budget that scales with chain depth (so a
|
|
852
|
+
* legitimate deep delegation always fits while bounded collisions stay
|
|
853
|
+
* bounded). A hostile zone publishing many colliding keys / signatures is
|
|
854
|
+
* refused with <code>dnssec/too-many-colliding-keys</code> /
|
|
855
|
+
* <code>dnssec/too-many-dnskeys</code> / <code>dnssec/too-many-ds</code> /
|
|
856
|
+
* <code>dnssec/too-many-links</code> /
|
|
857
|
+
* <code>dnssec/validation-budget-exceeded</code> rather than driving
|
|
858
|
+
* O(keys x sigs) verifications. (NSEC3 iteration counts are separately
|
|
859
|
+
* capped at 150 per RFC 9276 / the CVE-2023-50868 fix.)
|
|
860
|
+
*
|
|
798
861
|
* @opts
|
|
799
862
|
* {
|
|
800
863
|
* links: [ { // ordered root-first
|
|
@@ -816,14 +879,35 @@ function verifyChain(opts) {
|
|
|
816
879
|
validateOpts.requireObject(opts, "dnssec.verifyChain", DnssecError);
|
|
817
880
|
validateOpts(opts, ["links", "trustAnchors", "at"], "dnssec.verifyChain");
|
|
818
881
|
if (!Array.isArray(opts.links) || opts.links.length === 0) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyChain: opts.links must be a non-empty array");
|
|
882
|
+
// Cap delegation depth — a real chain can't be deeper than a DNS name's
|
|
883
|
+
// label count (RFC 1035), and the per-response validation budget below
|
|
884
|
+
// scales with this, so it must be bounded.
|
|
885
|
+
if (opts.links.length > MAX_CHAIN_LINKS) {
|
|
886
|
+
throw new DnssecError("dnssec/too-many-links",
|
|
887
|
+
"dnssec.verifyChain: " + opts.links.length + " chain links (cap " +
|
|
888
|
+
MAX_CHAIN_LINKS + ") — refused as an amplification vector");
|
|
889
|
+
}
|
|
819
890
|
var anchors = opts.trustAnchors !== undefined ? opts.trustAnchors : DEFAULT_ROOT_ANCHORS;
|
|
820
891
|
if (!Array.isArray(anchors) || anchors.length === 0) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyChain: opts.trustAnchors must be a non-empty array");
|
|
821
892
|
|
|
893
|
+
// KeyTrap budget shared across every signature-validation in this
|
|
894
|
+
// response, scaled to the declared chain depth so a legitimate deep
|
|
895
|
+
// delegation (2N-1 verifies) always fits while bounded collisions stay
|
|
896
|
+
// bounded. Chain length is capped above, so this can't be inflated.
|
|
897
|
+
var budget = { remaining: opts.links.length * MAX_VALIDATIONS_PER_LINK };
|
|
898
|
+
|
|
822
899
|
var trustedKeys = null, path = [];
|
|
823
900
|
for (var i = 0; i < opts.links.length; i++) {
|
|
824
901
|
var link = opts.links[i];
|
|
825
902
|
if (!link || typeof link.zone !== "string" || link.zone === "") throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].zone is required");
|
|
826
903
|
if (!Array.isArray(link.dnskeys) || link.dnskeys.length === 0) throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].dnskeys must be a non-empty array");
|
|
904
|
+
// KeyTrap: bound the DNSKEY RRset size per zone so a giant key set
|
|
905
|
+
// can't blow up the key-tag scan / candidate fan-out.
|
|
906
|
+
if (link.dnskeys.length > MAX_DNSKEYS_PER_ZONE) {
|
|
907
|
+
throw new DnssecError("dnssec/too-many-dnskeys",
|
|
908
|
+
"dnssec.verifyChain: links[" + i + "] has " + link.dnskeys.length +
|
|
909
|
+
" DNSKEYs (cap " + MAX_DNSKEYS_PER_ZONE + ") — refused as a KeyTrap (CVE-2023-50387) amplification vector");
|
|
910
|
+
}
|
|
827
911
|
if (!link.dnskeyRrsig || typeof link.dnskeyRrsig !== "object") throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].dnskeyRrsig is required");
|
|
828
912
|
|
|
829
913
|
// 1. The DNSKEY RRset is self-signed by one of its own keys (trying
|
|
@@ -832,7 +916,8 @@ function verifyChain(opts) {
|
|
|
832
916
|
{ name: link.zone, type: "DNSKEY", rdatas: link.dnskeys, at: opts.at },
|
|
833
917
|
link.dnskeyRrsig,
|
|
834
918
|
_keysByTag(link.dnskeys, link.dnskeyRrsig.keyTag),
|
|
835
|
-
"dnssec/chain-no-signing-key", "dnssec.verifyChain: no DNSKEY in '" + link.zone + "' verifies the DNSKEY RRSIG"
|
|
919
|
+
"dnssec/chain-no-signing-key", "dnssec.verifyChain: no DNSKEY in '" + link.zone + "' verifies the DNSKEY RRSIG",
|
|
920
|
+
budget
|
|
836
921
|
);
|
|
837
922
|
|
|
838
923
|
// 2. Establish trust in the signing key.
|
|
@@ -851,11 +936,19 @@ function verifyChain(opts) {
|
|
|
851
936
|
if (!Array.isArray(link.dsRdatas) || link.dsRdatas.length === 0 || !link.dsRrsig || typeof link.dsRrsig !== "object") {
|
|
852
937
|
throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "] needs dsRdatas + dsRrsig (DS served by the parent)");
|
|
853
938
|
}
|
|
939
|
+
// Bound the parent-supplied DS RRset — the DS-match loop below
|
|
940
|
+
// iterates it, and an oversize set is an amplification vector.
|
|
941
|
+
if (link.dsRdatas.length > MAX_DS_RECORDS) {
|
|
942
|
+
throw new DnssecError("dnssec/too-many-ds",
|
|
943
|
+
"dnssec.verifyChain: links[" + i + "] has " + link.dsRdatas.length +
|
|
944
|
+
" DS records (cap " + MAX_DS_RECORDS + ") — refused as an amplification vector");
|
|
945
|
+
}
|
|
854
946
|
_verifyRrsetWithAnyKey(
|
|
855
947
|
{ name: link.zone, type: "DS", rdatas: link.dsRdatas, at: opts.at },
|
|
856
948
|
link.dsRrsig,
|
|
857
949
|
_keysByTag(trustedKeys, link.dsRrsig.keyTag),
|
|
858
|
-
"dnssec/chain-no-parent-key", "dnssec.verifyChain: no trusted parent key verifies the DS RRSIG for '" + link.zone + "'"
|
|
950
|
+
"dnssec/chain-no-parent-key", "dnssec.verifyChain: no trusted parent key verifies the DS RRSIG for '" + link.zone + "'",
|
|
951
|
+
budget
|
|
859
952
|
);
|
|
860
953
|
var dsMatched = false;
|
|
861
954
|
for (var d = 0; d < link.dsRdatas.length; d++) {
|
|
@@ -681,6 +681,14 @@ function parse(input, opts) {
|
|
|
681
681
|
"toml/redefine");
|
|
682
682
|
}
|
|
683
683
|
t = sub[sub.length - 1];
|
|
684
|
+
// The array's last element must itself be a table to descend
|
|
685
|
+
// into. A plain VALUE array (e.g. `a = [3]` then `[a.s]`) has a
|
|
686
|
+
// scalar last element — descending would set a property on a
|
|
687
|
+
// number and throw a raw TypeError; refuse it cleanly instead.
|
|
688
|
+
if (t === null || typeof t !== "object" || Array.isArray(t)) {
|
|
689
|
+
throw _err("cannot descend into '" + seg +
|
|
690
|
+
"' — it is a value array, not an array of tables", "toml/redefine");
|
|
691
|
+
}
|
|
684
692
|
continue;
|
|
685
693
|
}
|
|
686
694
|
if (typeof sub !== "object" || sub === null) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.14",
|
|
4
|
+
"date": "2026-05-27",
|
|
5
|
+
"headline": "DNSSEC chain validation now bounds KeyTrap (CVE-2023-50387) amplification with hard caps",
|
|
6
|
+
"summary": "b.network.dns.dnssec.verifyChain tried every DNSKEY whose 16-bit key tag matched an RRSIG, with no cap on how many candidates or total signature verifications a single response could drive. A hostile zone publishing many DNSKEYs sharing one key tag (plus matching RRSIGs) could force O(keys x signatures) full public-key verifications from one query — the KeyTrap denial-of-service (CVE-2023-50387). Validation is now bounded by non-configurable caps that match the BIND / Unbound mitigations: at most 4 same-tag candidate keys are tried per RRSIG, at most 64 DNSKEYs per zone link and 16 DS records per delegation are accepted, the chain is at most 128 links deep, and the whole response is held to a signature-validation budget that scales with chain depth (so a legitimate deep delegation is never false-rejected while bounded collisions stay bounded); exceeding any of these refuses the response rather than performing the work. Separately, a domain name that encodes to more than 255 octets is now refused at canonicalization (RFC 1035 §2.3.4), which also bounds the NSEC3 closest-encloser label enumeration, and the NSEC3 iteration ceiling is lowered from 500 to 150 to match the BIND 9.16.33+ / Unbound 1.17.1 fix for the sibling CVE-2023-50868.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Security",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`verifyChain` caps colliding-key fan-out and total signature validations (KeyTrap / CVE-2023-50387)",
|
|
13
|
+
"body": "A zone advertising many same-key-tag DNSKEYs and RRSIGs can no longer drive unbounded public-key verifications. New refusals: `dnssec/too-many-colliding-keys` (>4 same-tag candidates per RRSIG), `dnssec/too-many-dnskeys` (>64 DNSKEYs per zone link), `dnssec/too-many-ds` (>16 DS records per delegation), `dnssec/too-many-links` (chain deeper than 128), and `dnssec/validation-budget-exceeded` (signature validations beyond the depth-scaled budget). The caps are intentionally non-configurable — they sit well above any legitimate zone, and the budget scales with chain depth so deep delegations validate normally."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "Domain-name octet cap + lower NSEC3 iteration ceiling",
|
|
17
|
+
"body": "A name that canonicalizes to more than 255 octets is refused (`dnssec/bad-name`, RFC 1035 §2.3.4), which bounds the per-label NSEC3 closest-encloser enumeration (CVE-2023-50868 class). The default NSEC3 iteration ceiling drops from 500 to 150, matching the BIND 9.16.33+ / Unbound 1.17.1 post-CVE defaults (RFC 9276 recommends 0)."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.15",
|
|
4
|
+
"date": "2026-05-27",
|
|
5
|
+
"headline": "Corrected more source citations and made deferred/reserved options honest in their docs",
|
|
6
|
+
"summary": "A second accuracy pass over source threat-annotations and option docs. Three citation corrections: the base64url strict-decode guard cited CVE-2022-0235 (which is actually a node-fetch cookie-leak, unrelated) — it now names the weakness class it defends (CWE-347 / CWE-1286 signature canonicalization); the glob consecutive-wildcard ReDoS cap cited the wrong library (the CVE-2026-26996 ReDoS is minimatch, not picomatch — the adjacent picomatch one is CVE-2026-33671); and CVE-2026-32178 is reframed to the CWE-138 header-injection-spoofing class the public record actually documents (and dropped from the end-of-data SMTP-smuggling list, which is a different class). Several options/statuses are now honest about not-yet-implemented surface: b.archive.read.zip.fromTrustedStream is marked experimental (its methods throw and its options aren't honored yet — the example now shows the supported buffer-then-random-access path); b.acme revokeCert's useCertKey / certPrivateKey are marked reserved (the cert-key path throws; account-key signing is the supported default); and a stale message claiming passkey break-glass factors were a future feature is removed (passkeys are a live allowed factor). No runtime behaviour changes beyond message/doc text.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Fixed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "Corrected misattributed CVE citations in source threat-annotations",
|
|
13
|
+
"body": "`b.crypto.fromBase64Url`'s strict-decode guard cited CVE-2022-0235 (a node-fetch header-leak, unrelated to base64/JWT decoding); it now cites the weakness class it actually defends — CWE-347 / CWE-1286 signature canonicalization. `b.guardRegex`'s consecutive-`*` cap attributed CVE-2026-26996 to picomatch; that ReDoS is in minimatch (the picomatch ReDoS it also defends is CVE-2026-33671) — the library name is corrected. CVE-2026-32178 is reframed to the CWE-138 header-injection spoofing class the public advisory documents, and removed from the end-of-data SMTP-smuggling trio (a distinct class). No behaviour change — the defenses are unchanged."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"heading": "Changed",
|
|
19
|
+
"items": [
|
|
20
|
+
{
|
|
21
|
+
"title": "Deferred / reserved surface now documented honestly",
|
|
22
|
+
"body": "`b.archive.read.zip.fromTrustedStream` is marked `experimental` — its `inspect`/`entries`/`extract` throw and its `bombPolicy`/`audit` options aren't honored yet; the documented example now shows the supported path (buffer the stream, then use the random-access reader). `b.acme` `revokeCert`'s `useCertKey` / `certPrivateKey` options are marked reserved (the cert-key-signed-revocation path throws; account-key signing, the default, covers mainstream CAs). A `b.breakGlass` policy error and comment that called passkey factors a future feature are corrected — passkeys are a live allowed factor."
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.13.16",
|
|
4
|
+
"date": "2026-05-27",
|
|
5
|
+
"headline": "`b.mail.agent` docs now describe the facade accurately, and not-yet-wired verbs point to the primitive to use",
|
|
6
|
+
"summary": "b.mail.agent's module documentation claimed it was \"the standardization contract for every mail protocol\" that JMAP / IMAP / POP3 all route through — but no protocol server actually dispatches through the agent (the framework's own JMAP EmailSubmission handler composes b.mail.send.deliver directly), and the compose / send / reply / forward, sieve.list / sieve.activate, identity / vacation / mdn.* and export / job / import verbs throw mail-agent/not-implemented. The docs are corrected to describe what the agent is: a mailbox-access facade (RBAC + posture + audit + dispatch around a mail store) whose read surface plus the mailbox-mutation and Sieve-upload methods are wired, with the remaining verbs not yet routed through it. Those verbs' error message now names the underlying primitive to compose directly (b.mail.send.deliver, b.mail.sieve, b.mailMdn, …) instead of citing a version tag that had long passed. The public WIRED_AT export (a method→version map that no longer reflected reality) is replaced by COMPOSE_HINT (a method→primitive-to-compose map). No behaviour change: the same methods are wired or throw exactly as before.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Changed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.mail.agent` documentation corrected; not-implemented errors point to the primitive to compose",
|
|
13
|
+
"body": "The `@module` / `@card` no longer claim the agent is the universal protocol-dispatch contract — it's documented as a mailbox-access facade with a wired read + mutation + Sieve-upload surface, and the compose/send/identity/vacation/MDN/export verbs documented as not yet routed through it (compose the underlying primitive directly until a protocol server adopts the agent). The `mail-agent/not-implemented` error now names that primitive (e.g. `b.mail.send.deliver`) rather than a passed version tag."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"heading": "Removed",
|
|
19
|
+
"items": [
|
|
20
|
+
{
|
|
21
|
+
"title": "`b.mail.agent.WIRED_AT` export replaced by `COMPOSE_HINT`",
|
|
22
|
+
"body": "The `WIRED_AT` export mapped each method to a framework version that was supposed to \"light it up\" — versions that have all shipped without the wiring, so the map was misleading. It is replaced by `COMPOSE_HINT`, mapping each not-yet-wired method to the primitive an operator composes directly. Operators reading `b.mail.agent.WIRED_AT` should read `b.mail.agent.COMPOSE_HINT` instead (pre-1.0: no compatibility shim)."
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -13545,6 +13545,15 @@ function testTomlSecurityRejections() {
|
|
|
13545
13545
|
catch (e) { threwRedefine = e.code === "toml/redefine"; }
|
|
13546
13546
|
check("toml: table redefinition rejected", threwRedefine);
|
|
13547
13547
|
|
|
13548
|
+
// Table header descending through a VALUE array (not an array-of-tables)
|
|
13549
|
+
// — `a = [3]` then `[a.s]` walks into the array's scalar element. A fuzz
|
|
13550
|
+
// input hit this; it must refuse cleanly, not throw a raw TypeError.
|
|
13551
|
+
var threwValueArrayDescend = null;
|
|
13552
|
+
try { b.parsers.toml.parse("a = [3]\n[a.s]\n"); }
|
|
13553
|
+
catch (e) { threwValueArrayDescend = e.code; }
|
|
13554
|
+
check("toml: table header descending into a value array rejected cleanly",
|
|
13555
|
+
threwValueArrayDescend === "toml/redefine");
|
|
13556
|
+
|
|
13548
13557
|
// Size cap
|
|
13549
13558
|
var threwSize = false;
|
|
13550
13559
|
try { b.parsers.toml.parse("a = \"" + "x".repeat(2000) + "\"", { maxBytes: 1000 }); }
|
|
@@ -541,23 +541,19 @@ var STALE_DEFER_ALLOWLIST = {
|
|
|
541
541
|
// operator-feeds-metadata escape hatch. Defer-with-condition.
|
|
542
542
|
"lib/ai-content-detect.js": ["IPTC PhotoMetadata reader lands in v0.10.9"],
|
|
543
543
|
// GAP BACKLOG (being worked down — these are real overdue items):
|
|
544
|
-
// archive-read ZIP64 read
|
|
544
|
+
// archive-read ZIP64 read (promised v0.12.8) — building. (The
|
|
545
|
+
// fromTrustedStream defer was reworded to a version-free "not
|
|
546
|
+
// implemented / re-opens when needed" in v0.13.15, so it no longer
|
|
547
|
+
// needs an allowlist entry.)
|
|
545
548
|
"lib/archive-read.js": [
|
|
546
549
|
"not supported in v0.12.7. Will land",
|
|
547
550
|
"switch to tar — lands v0.12.8",
|
|
548
551
|
"carries ZIP64 sentinel sizes (not supported in v0.12.7)",
|
|
549
|
-
"deferred to v0.12.8 alongside the tar reader",
|
|
550
|
-
"fromTrustedStream.inspect() is deferred to v0.12.8",
|
|
551
|
-
"fromTrustedStream.entries() is deferred to v0.12.8",
|
|
552
|
-
"fromTrustedStream.extract() is deferred to v0.12.8",
|
|
553
552
|
],
|
|
554
553
|
"lib/safe-archive.js": [
|
|
555
554
|
"tar lands v0.12.8, gz v0.12.9",
|
|
556
555
|
"fromTrustedStream` is deferred to v0.12.8",
|
|
557
556
|
],
|
|
558
|
-
// STALE comments (feature shipped; phrased as future) — correcting to past
|
|
559
|
-
// tense as the backlog is worked; allowlisted until then.
|
|
560
|
-
"lib/break-glass.js": ["passkey lands in v0.5.2"],
|
|
561
557
|
};
|
|
562
558
|
|
|
563
559
|
function testNoStaleDefers() {
|
|
@@ -390,12 +390,106 @@ function testKeyTagCollision() {
|
|
|
390
390
|
check("verifyChain: validates despite a colliding-tag key in the signed set", out.ok === true);
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
// Sign a DS RRset (type 43) with a parent EC key — mirrors
|
|
394
|
+
// _signDnskeyRrset but for the DS record type.
|
|
395
|
+
function _signDsRrset(zone, dsRdatas, parentPriv, parentSignerRdata, inc, exp) {
|
|
396
|
+
var owner = _canonName(zone), ttl = _u32b(3600);
|
|
397
|
+
var labels = zone.replace(/\.$/, "") === "" ? 0 : zone.replace(/\.$/, "").split(".").length;
|
|
398
|
+
var keyTag = b.network.dns.dnssec.keyTag(parentSignerRdata);
|
|
399
|
+
var sorted = dsRdatas.slice().sort(Buffer.compare);
|
|
400
|
+
var rrs = [];
|
|
401
|
+
sorted.forEach(function (rd) { rrs.push(owner, _u16b(43), _u16b(1), ttl, _u16b(rd.length), rd); });
|
|
402
|
+
var prefix = Buffer.concat([_u16b(43), Buffer.from([13, labels]), ttl, _u32b(exp), _u32b(inc), _u16b(keyTag), _canonName(zone)]);
|
|
403
|
+
var signed = Buffer.concat([prefix].concat(rrs));
|
|
404
|
+
var signature = nodeCrypto.sign("sha256", signed, { key: parentPriv, dsaEncoding: "ieee-p1363" });
|
|
405
|
+
return { algorithm: 13, labels: labels, originalTtl: 3600, expiration: exp, inception: inc, keyTag: keyTag, signerName: zone, signature: signature };
|
|
406
|
+
}
|
|
407
|
+
function _dsRdata(tag, alg, digest) {
|
|
408
|
+
return Buffer.concat([_u16b(tag), Buffer.from([alg, 2]), digest]); // keyTag || alg || digestType=2(SHA-256) || digest
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// The per-response signature-validation budget scales with chain depth, so
|
|
412
|
+
// a legitimate deep delegation (here 11 links → ~21 verifies, well past the
|
|
413
|
+
// old fixed 16) validates rather than hitting dnssec/validation-budget-
|
|
414
|
+
// exceeded (the regression Codex flagged on PR #236).
|
|
415
|
+
function testDeepChainBudget() {
|
|
416
|
+
var now = Math.floor(Date.now() / 1000), inc = now - 60, exp = now + 86400;
|
|
417
|
+
var N = 11;
|
|
418
|
+
var keys = [];
|
|
419
|
+
for (var i = 0; i < N; i++) {
|
|
420
|
+
var kp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
421
|
+
keys.push({ rd: _ecDnskey(kp.publicKey), priv: kp.privateKey, zone: "z" + i + "." });
|
|
422
|
+
}
|
|
423
|
+
var links = [];
|
|
424
|
+
for (var j = 0; j < N; j++) {
|
|
425
|
+
var k = keys[j];
|
|
426
|
+
var link = {
|
|
427
|
+
zone: k.zone,
|
|
428
|
+
dnskeys: [k.rd],
|
|
429
|
+
dnskeyRrsig: _signDnskeyRrset(k.zone, [k.rd], k.priv, k.rd, inc, exp),
|
|
430
|
+
};
|
|
431
|
+
if (j > 0) {
|
|
432
|
+
// DS of THIS zone's key, signed by the PARENT (previous link's key).
|
|
433
|
+
var digest = nodeCrypto.createHash("sha256").update(Buffer.concat([_canonName(k.zone), k.rd])).digest();
|
|
434
|
+
var dsRd = _dsRdata(b.network.dns.dnssec.keyTag(k.rd), 13, digest);
|
|
435
|
+
link.dsRdatas = [dsRd];
|
|
436
|
+
link.dsRrsig = _signDsRrset(k.zone, [dsRd], keys[j - 1].priv, keys[j - 1].rd, inc, exp);
|
|
437
|
+
}
|
|
438
|
+
links.push(link);
|
|
439
|
+
}
|
|
440
|
+
// Trust anchor = DS digest of the root (link 0) key.
|
|
441
|
+
var rootDigest = nodeCrypto.createHash("sha256").update(Buffer.concat([_canonName(keys[0].zone), keys[0].rd])).digest();
|
|
442
|
+
var anchor = [{ keyTag: b.network.dns.dnssec.keyTag(keys[0].rd), algorithm: 13, digestType: 2, digest: rootDigest }];
|
|
443
|
+
var out = b.network.dns.dnssec.verifyChain({ links: links, trustAnchors: anchor, at: new Date(now * 1000) });
|
|
444
|
+
check("deep chain (11 links, ~21 verifies) validates under the depth-scaled budget",
|
|
445
|
+
out.ok === true && out.path.length === N);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// KeyTrap (CVE-2023-50387) amplification caps. The caps fire on COUNT
|
|
449
|
+
// checks before any signature verification, so a single real EC DNSKEY
|
|
450
|
+
// rdata repeated to hit each threshold is enough.
|
|
451
|
+
function testKeyTrapCaps() {
|
|
452
|
+
function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
|
|
453
|
+
var kp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
454
|
+
var rd = _ecDnskey(kp.publicKey);
|
|
455
|
+
var tag = b.network.dns.dnssec.keyTag(rd);
|
|
456
|
+
var now = Math.floor(Date.now() / 1000);
|
|
457
|
+
var rrsig = { algorithm: 13, labels: 1, originalTtl: 3600, expiration: now + 86400,
|
|
458
|
+
inception: now - 60, keyTag: tag, signerName: "test.", signature: Buffer.alloc(64) };
|
|
459
|
+
var anchor = [{ keyTag: tag, algorithm: 13, digestType: 2, digest: Buffer.alloc(32) }];
|
|
460
|
+
|
|
461
|
+
// > MAX_COLLIDING_KEYS (4) keys sharing one tag (5 identical rdatas all
|
|
462
|
+
// resolve to the same tag) → refused before any verify.
|
|
463
|
+
var fiveSameTag = [rd, rd, rd, rd, rd];
|
|
464
|
+
check("KeyTrap: >4 same-tag DNSKEY candidates refused",
|
|
465
|
+
code(function () { b.network.dns.dnssec.verifyChain({
|
|
466
|
+
links: [{ zone: "test.", dnskeys: fiveSameTag, dnskeyRrsig: rrsig }],
|
|
467
|
+
trustAnchors: anchor, at: new Date(now * 1000) }); }) === "dnssec/too-many-colliding-keys");
|
|
468
|
+
|
|
469
|
+
// > MAX_DNSKEYS_PER_ZONE (64) in one zone's RRset → refused.
|
|
470
|
+
var bigSet = []; for (var i = 0; i < 65; i++) bigSet.push(rd);
|
|
471
|
+
check("KeyTrap: oversize DNSKEY RRset (>64) refused",
|
|
472
|
+
code(function () { b.network.dns.dnssec.verifyChain({
|
|
473
|
+
links: [{ zone: "test.", dnskeys: bigSet, dnskeyRrsig: rrsig }],
|
|
474
|
+
trustAnchors: anchor, at: new Date(now * 1000) }); }) === "dnssec/too-many-dnskeys");
|
|
475
|
+
|
|
476
|
+
// A name encoding to > 255 octets (128 single-char labels = 257 octets)
|
|
477
|
+
// is refused at canonicalization (RFC 1035 §2.3.4) — bounds the NSEC3
|
|
478
|
+
// closest-encloser label enumeration.
|
|
479
|
+
var longName = new Array(129).join("a.") + "a"; // 129 labels of "a"
|
|
480
|
+
check("KeyTrap: name exceeding 255 octets refused",
|
|
481
|
+
code(function () { b.network.dns.dnssec.verifyDs({ ownerName: longName,
|
|
482
|
+
dnskeyRdata: rd, ds: { keyTag: tag, algorithm: 13, digestType: 2, digest: Buffer.alloc(32) } }); }) === "dnssec/bad-name");
|
|
483
|
+
}
|
|
484
|
+
|
|
393
485
|
async function run() {
|
|
394
486
|
testSurface();
|
|
395
487
|
testRealVectors();
|
|
396
488
|
testRefusals();
|
|
397
489
|
testVerifyDs();
|
|
398
490
|
testVerifyChain();
|
|
491
|
+
testKeyTrapCaps();
|
|
492
|
+
testDeepChainBudget();
|
|
399
493
|
testNsec3Real();
|
|
400
494
|
testNsec3Caps();
|
|
401
495
|
testNsec3OptOut();
|
|
@@ -46,7 +46,7 @@ function testSurface() {
|
|
|
46
46
|
check("mail.agent.consumer is fn", typeof b.mail.agent.consumer === "function");
|
|
47
47
|
check("MailAgentError is fn", typeof b.mail.agent.MailAgentError === "function");
|
|
48
48
|
check("SCOPE_FOR_METHOD frozen", Object.isFrozen(b.mail.agent.SCOPE_FOR_METHOD));
|
|
49
|
-
check("
|
|
49
|
+
check("COMPOSE_HINT frozen", Object.isFrozen(b.mail.agent.COMPOSE_HINT));
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
async function testCreateRequiresStore() {
|
|
@@ -100,8 +100,14 @@ async function run() {
|
|
|
100
100
|
// Poll until the watcher surfaces the target event. macOS fs.watch
|
|
101
101
|
// on CI runners can take 2-3s to deliver write events under load;
|
|
102
102
|
// the helper widens the default wait budget to 15s for that drift.
|
|
103
|
+
// Re-write a.txt on every poll: FSEvents may not be listening yet
|
|
104
|
+
// when the initial write (above) lands, and an event for a write
|
|
105
|
+
// that happened before the watch primed is dropped entirely — no
|
|
106
|
+
// amount of polling recovers it. Re-touching until the watch
|
|
107
|
+
// catches one closes that start-up race without a fixed prime-sleep.
|
|
103
108
|
try {
|
|
104
109
|
await waitForWatcher(function () {
|
|
110
|
+
fs.writeFileSync(path.join(tmpDir, "a.txt"), "hello");
|
|
105
111
|
w._flushForTest();
|
|
106
112
|
return changes.some(function (e) {
|
|
107
113
|
return e.relativePath === "a.txt" && e.type === "file";
|
package/package.json
CHANGED