@blamejs/blamejs-shop 0.2.1 → 0.2.3
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 +4 -4
- package/lib/asset-manifest.json +5 -5
- package/lib/storefront.js +24 -21
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/SECURITY.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- 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/test/00-primitives.js +9 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dnssec.test.js +94 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.2.x
|
|
10
10
|
|
|
11
|
+
- v0.2.3 (2026-05-27) — **Admin console adopts the dark violet brand — the operator dashboard now matches the storefront.** The admin console has been restyled from its previous light theme and orange accent onto the same dark violet identity as the storefront: a neutral near-black canvas with violet reserved for accents, the self-hosted Hanken Grotesk and Space Mono typefaces in place of Inter, and Space Mono carrying the terminal-style chrome (header mark, navigation, micro-labels, figures, SKUs, order ids, and code). Stat cards, tables, status pills, forms, buttons, banners, nav cards, and the sign-in card are all recoloured for the dark surface with contrast preserved — order statuses keep their meaning (green for paid/shipped, amber for pending, violet for refunded, muted for cancelled), form inputs read clearly on the dark wells, and the revenue sparkline is drawn in the brand violet. Operators on the default theme get the new console on upgrade; no admin functionality, routes, or data change. **Changed:** *Dark violet admin theme* — The admin stylesheet is rebuilt on the storefront's palette — near-black surfaces, neutral hairline borders, and violet reserved for accents (active nav, primary buttons, the `/ admin` mark, focus states, and the revenue sparkline) rather than the former light paper and orange. Self-hosted Hanken Grotesk (display/body) and Space Mono (chrome/mono) replace Inter, loaded under the strict `font-src 'self'` policy. · *Components recoloured for the dark surface* — Stat cards, the orders table, status pills, filter chips, forms, buttons (primary/ghost/danger), banners, nav cards, the gift-card detail grid, and the sign-in card are all restyled for dark with contrast maintained. Order-status pills keep their semantics — green for paid/shipped/fulfilling/delivered, amber for pending, violet for refunded, muted for cancelled. Form inputs sit on recessed dark wells with light text and a violet focus ring.
|
|
12
|
+
|
|
13
|
+
- v0.2.2 (2026-05-27) — **Bespoke line-art illustrations across the storefront — category tiles, image placeholders, and every empty state.** The storefront's stand-in graphics are now a single, cohesive set of hand-drawn line illustrations in the brand violet, replacing the previous mix of gradient-only tiles, single-letter image marks, and emoji. The six featured-collection tiles carry a matching icon (apparel, hardware, digital, subscriptions, bundles, gift cards). Products without an image — in the grid and on the product page — show a bespoke "no image yet" placeholder instead of a letter glyph. Every empty state has its own illustration: cart, search, wishlist, saved-for-later, addresses, returns, and orders. The artwork is inline SVG (no extra files fetched, consistent with the strict asset policy) and adapts to the surface — the fine detail strokes follow the text colour, so the same set reads correctly on a dark or a light background. **Changed:** *Category tiles carry line-art icons* — Each of the six featured-collection tiles now shows a violet line icon for its category, centered over the existing tile gradient. The tile links and labels are unchanged. · *Bespoke image placeholder replaces the letter mark* — When a product has no image, the grid card and the product-page gallery render a line-art "no image yet" placeholder — an isometric package on a faint grid (the product page adds a shield motif and a terminal-prompt accent) — instead of the previous single uppercase letter. The no-image grid card now uses the same media-card layout as image-bearing cards for a consistent grid. · *Every empty state has matching artwork* — The empty cart, no-search-results, empty wishlist, saved-for-later, addresses, returns, and orders states now show a bespoke line illustration in place of the previous emoji or unicode glyphs, so the storefront and account sections share one visual language. · *Inline, theme-adaptive SVG* — All of the new artwork is inline SVG with presentation attributes only — nothing is fetched from a CDN and no inline styles are used, consistent with the strict content-security and asset policies. The detail strokes use currentColor, so the set adapts to the background it sits on. The edge and container renderers emit identical markup.
|
|
14
|
+
|
|
11
15
|
- v0.2.1 (2026-05-27) — **Theme tuning — stronger contrast, a cleaner product buy-box, and natural code-sample colors.** A polish pass over the new dark theme. The palette is rebalanced so the brand violet reads as a deliberate accent rather than the whole field: surfaces, borders, and shadows are now neutral dark, body text and prices are high-contrast near-white, and violet is concentrated on calls-to-action, the hero accent, and the newsletter band. The product page's buy-box is now a single contained card — price, variant, quantity, and add-to-cart group cleanly with proper spacing and alignment instead of blending into the column, and the wishlist/compare controls sit in their own separated row. The homepage code sample uses natural syntax-highlight colours (green strings, blue functions, violet keywords) with the usual window dots. The home collection tiles are calmer dark tiles with a subtle violet glow rather than solid magenta swatches. **Changed:** *Rebalanced palette for contrast* — Surfaces (`--surface`, card and raised tiers) and borders are now neutral dark instead of violet-tinted; secondary text and prices are high-contrast near-white; default shadows are neutral. The violet accent is reserved for primary buttons, the hero accent word, focus states, and the newsletter/framework bands — so contrast and hierarchy are clearer and the page no longer reads as a single wash of magenta. · *Product buy-box is a contained, aligned card* — Price, variant chip(s), quantity, and the full-width add-to-cart now sit in one bordered buy-box card with consistent spacing, the price rendered in high-contrast white, and the post-quantum-secured-checkout trust line as a calm note beneath it. The wishlist and compare controls are grouped into their own row, separated from the buy-box. No change to add-to-cart, variant, or currency behaviour. · *Natural code-sample colours and calmer collection tiles* — The homepage server-rendered code sample now uses a natural console palette — green strings, blue function names, violet keywords, grey comments — with red/yellow/green window dots. The home collection tiles are dark with a subtle, position-varied violet glow instead of solid violet gradient fills.
|
|
12
16
|
|
|
13
17
|
- v0.2.0 (2026-05-27) — **A new storefront visual identity — a dark, violet brand theme with self-hosted typefaces and a redesigned product buy-box.** The default storefront theme has been redesigned around the project's actual brand: a dark, near-black canvas with a violet-to-magenta accent system that matches the blamejs shield-and-terminal mark, replacing the previous light theme and its unrelated orange accent. The stylesheet is restructured onto a semantic design-token layer (a purpose tier — surface, text, border, accent, price — sitting over the raw palette), so colour, contrast, and surfaces are now consistent and themeable rather than hard-coded per component. Two distinctive open-licensed typefaces are self-hosted in place of Inter: Hanken Grotesk for display and body, Space Mono for the terminal-style chrome (navigation, labels, SKUs, prices). The product page's variant selector is rebuilt from a data table into a proper buy-box: a prominent price, the variant(s) as selectable chips, a full-width add-to-cart, and a post-quantum-secured-checkout trust line — the add-to-cart, variant, and currency-conversion behaviour is unchanged. Accessibility is preserved (contrast targets met on the dark surfaces, focus-visible, reduced-motion guards, the consent banner restyled as legible dark glass). Operators on the default theme get the new look on upgrade; a custom theme passed via the theme primitive is unaffected. **Changed:** *Dark violet brand theme replaces the light theme* — The default theme is now a dark, near-black storefront with a violet→magenta accent family (matching the logo's shield/terminal mark) carried across the chrome, hero, cards, forms, badges, switchers, and footer. The previous light canvas and its off-brand orange accent are gone. Depth comes from hard-edged and soft violet shadows and a faint circuit-grid texture; motion (a staggered page-load reveal, a glitch treatment on the hero accent word, a terminal cursor) is CSS-only and disabled under `prefers-reduced-motion`. · *Semantic design-token layer + self-hosted typefaces* — The stylesheet now has a primitive→semantic token structure (`--surface`/`--surface-raised`, `--text`/`--text-muted`, `--border`, `--color-accent`, `--color-price`, glow tokens) so components reference purpose, not raw values — fixing the previously undefined `--font-sans`/`--danger-l` tokens and a duplicated button definition along the way. Hanken Grotesk (display/body) and Space Mono (mono chrome) are self-hosted as OFL woff2, replacing Inter; nothing is fetched from a font CDN, consistent with the strict `font-src 'self'` policy. · *Product buy-box redesign* — The product page's variant table is replaced by a buy-box: a large monospace price, each variant as a selectable pill chip (with a clear selected state, fully operable with JavaScript off), a full-width add-to-cart, and a trust line stating the checkout is post-quantum secured. The underlying add-to-cart form, multi-variant selection, and multi-currency price formatting are unchanged; products with many variants keep a compact fallback. The PDP trust badge wording "Ships from origin" is replaced with a real delivery estimate.
|
package/lib/admin.js
CHANGED
|
@@ -2586,9 +2586,9 @@ function mount(router, deps) {
|
|
|
2586
2586
|
//
|
|
2587
2587
|
// Server-rendered HTML dashboard for `GET /admin/dashboard`. Reads
|
|
2588
2588
|
// the four analytics aggregates and lays them out in a single page
|
|
2589
|
-
// matching the storefront's brand
|
|
2590
|
-
// accent,
|
|
2591
|
-
// is rendered server-side from the revenue-by-day rows.
|
|
2589
|
+
// matching the storefront's dark violet brand (near-black canvas,
|
|
2590
|
+
// #AD38DB accent, Hanken Grotesk + Space Mono). No client-side JS —
|
|
2591
|
+
// the SVG sparkline is rendered server-side from the revenue-by-day rows.
|
|
2592
2592
|
|
|
2593
2593
|
var DASHBOARD_LAYOUT =
|
|
2594
2594
|
"<!DOCTYPE html>\n" +
|
|
@@ -2648,7 +2648,7 @@ function _sparkSvg(byDay, currency) {
|
|
|
2648
2648
|
return (i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1);
|
|
2649
2649
|
}).join(" ");
|
|
2650
2650
|
return "<div class=\"spark\"><svg viewBox=\"0 0 " + W + " " + H + "\" preserveAspectRatio=\"none\" aria-label=\"Revenue by day sparkline\">" +
|
|
2651
|
-
"<path d=\"" + path + "\" fill=\"none\" stroke=\"#
|
|
2651
|
+
"<path d=\"" + path + "\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>" +
|
|
2652
2652
|
"</svg></div>";
|
|
2653
2653
|
}
|
|
2654
2654
|
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.3",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
|
-
"integrity": "sha384-
|
|
6
|
-
"fingerprinted": "css/admin.
|
|
5
|
+
"integrity": "sha384-FgqvXcZygYkJjjOtXAeu9xi6AtYqkchPnJKcS0OeHm8lynhd9r4RfdpoT7P7j7/l",
|
|
6
|
+
"fingerprinted": "css/admin.afd7b964c1f6fe1b.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-iKbkn3AR4k733EgN+1Ju51qbKfz9yVmDk8lE2l99asnRGbSo4BJu4Lo+OTxcBME4",
|
|
10
|
+
"fingerprinted": "css/main.5678b2596038b019.css"
|
|
11
11
|
},
|
|
12
12
|
"js/consent.js": {
|
|
13
13
|
"integrity": "sha384-KKMQ0og8HPOykRRPpUyxX7dMhTvKySfVtpGX/jGWzZwNaN/c4OykvRvXpqBHcQST",
|
package/lib/storefront.js
CHANGED
|
@@ -512,11 +512,15 @@ var PRODUCT_CARD_IMAGE =
|
|
|
512
512
|
"</a>\n";
|
|
513
513
|
|
|
514
514
|
var PRODUCT_CARD =
|
|
515
|
-
"<
|
|
516
|
-
" <
|
|
517
|
-
"
|
|
518
|
-
"
|
|
519
|
-
"
|
|
515
|
+
"<a class=\"product-card\" href=\"/products/{{slug}}\">\n" +
|
|
516
|
+
" <figure class=\"product-card__media product-card__media--placeholder\">\n" +
|
|
517
|
+
" <svg class=\"media-ph__svg\" viewBox=\"0 0 160 120\" aria-hidden=\"true\"><rect width=\"160\" height=\"120\" fill=\"none\"/><g stroke=\"currentColor\" stroke-opacity=\"0.10\" stroke-width=\"1\"><path d=\"M0 30 H160 M0 60 H160 M0 90 H160 M40 0 V120 M80 0 V120 M120 0 V120\"/></g><g fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M80 38 L104 50 L104 76 L80 88 L56 76 L56 50 Z\"/><path d=\"M56 50 L80 62 L104 50 M80 62 V88\" stroke=\"#732A8D\"/><path d=\"M70 47 L74 50 L70 53\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M77 54 H86\" stroke=\"currentColor\" stroke-width=\"2\"/></g><text x=\"80\" y=\"106\" text-anchor=\"middle\" font-family=\"ui-monospace,Menlo,Consolas,monospace\" font-size=\"9\" letter-spacing=\"1.5\" fill=\"#6b6b78\">no image yet</text></svg>\n" +
|
|
518
|
+
" </figure>\n" +
|
|
519
|
+
" <div class=\"product-card__meta\">\n" +
|
|
520
|
+
" <h3 class=\"product-card__title\">{{title}}</h3>\n" +
|
|
521
|
+
" <p class=\"product-card__price\">{{price}}</p>\n" +
|
|
522
|
+
" </div>\n" +
|
|
523
|
+
"</a>\n";
|
|
520
524
|
|
|
521
525
|
// Render-time picker. Image-bearing cards become the dominant
|
|
522
526
|
// surface as soon as a product carries media; text-only cards
|
|
@@ -625,42 +629,42 @@ var HOME_HERO =
|
|
|
625
629
|
" </header>\n" +
|
|
626
630
|
" <div class=\"collections__grid\">\n" +
|
|
627
631
|
" <a class=\"collection-card\" href=\"/search?q=tee\">\n" +
|
|
628
|
-
" <div class=\"collection-card__art collection-card__art--1\" aria-hidden=\"true\"></div>\n" +
|
|
632
|
+
" <div class=\"collection-card__art collection-card__art--1\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M70 38 C74 44 86 44 90 38 L104 44 L112 58 L100 64 L96 58 L96 92 L64 92 L64 58 L60 64 L48 58 L56 44 Z\"/><path d=\"M71 40 C75 47 85 47 89 40\" stroke=\"#732A8D\" stroke-width=\"2\"/><path d=\"M73 76 H87\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-dasharray=\"2 3\"/></svg></div>\n" +
|
|
629
633
|
" <div class=\"collection-card__meta\">\n" +
|
|
630
634
|
" <h3>Apparel</h3>\n" +
|
|
631
635
|
" <p>Sized, colored, inventoried.</p>\n" +
|
|
632
636
|
" </div>\n" +
|
|
633
637
|
" </a>\n" +
|
|
634
638
|
" <a class=\"collection-card\" href=\"/search?q=edge\">\n" +
|
|
635
|
-
" <div class=\"collection-card__art collection-card__art--2\" aria-hidden=\"true\"></div>\n" +
|
|
639
|
+
" <div class=\"collection-card__art collection-card__art--2\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"58\" y=\"38\" width=\"44\" height=\"44\" rx=\"4\"/><rect x=\"70\" y=\"50\" width=\"20\" height=\"20\" rx=\"2\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"3\" fill=\"#AD38DB\" stroke=\"none\"/><path d=\"M66 38 V30 M76 38 V30 M86 38 V30 M96 38 V30\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M66 82 V90 M76 82 V90 M86 82 V90 M96 82 V90\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M58 48 H50 M58 60 H50 M58 72 H50\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M102 48 H110 M102 60 H110 M102 72 H110\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
|
|
636
640
|
" <div class=\"collection-card__meta\">\n" +
|
|
637
641
|
" <h3>Hardware</h3>\n" +
|
|
638
642
|
" <p>Serialized, warranty-tracked.</p>\n" +
|
|
639
643
|
" </div>\n" +
|
|
640
644
|
" </a>\n" +
|
|
641
645
|
" <a class=\"collection-card\" href=\"/search?q=license\">\n" +
|
|
642
|
-
" <div class=\"collection-card__art collection-card__art--3\" aria-hidden=\"true\"></div>\n" +
|
|
646
|
+
" <div class=\"collection-card__art collection-card__art--3\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M60 74 C50 74 48 62 57 59 C56 47 73 43 79 53 C88 47 100 54 97 64 C107 65 107 74 99 74 Z\" stroke=\"#732A8D\"/><path d=\"M78 60 V86\"/><path d=\"M70 78 L78 88 L86 78\"/><path d=\"M64 98 H92\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
|
|
643
647
|
" <div class=\"collection-card__meta\">\n" +
|
|
644
648
|
" <h3>Digital</h3>\n" +
|
|
645
649
|
" <p>License-key fulfillment.</p>\n" +
|
|
646
650
|
" </div>\n" +
|
|
647
651
|
" </a>\n" +
|
|
648
652
|
" <a class=\"collection-card\" href=\"/search?q=subscription\">\n" +
|
|
649
|
-
" <div class=\"collection-card__art collection-card__art--4\" aria-hidden=\"true\"></div>\n" +
|
|
653
|
+
" <div class=\"collection-card__art collection-card__art--4\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M56 50 A26 26 0 0 1 104 56\"/><path d=\"M104 70 A26 26 0 0 1 56 64\"/><path d=\"M104 42 L106 57 L91 55\" stroke=\"#AD38DB\"/><path d=\"M56 78 L54 63 L69 65\" stroke=\"#AD38DB\"/><circle cx=\"80\" cy=\"60\" r=\"6\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg></div>\n" +
|
|
650
654
|
" <div class=\"collection-card__meta\">\n" +
|
|
651
655
|
" <h3>Subscriptions</h3>\n" +
|
|
652
656
|
" <p>Stripe-backed recurring.</p>\n" +
|
|
653
657
|
" </div>\n" +
|
|
654
658
|
" </a>\n" +
|
|
655
659
|
" <a class=\"collection-card\" href=\"/search?q=bundle\">\n" +
|
|
656
|
-
" <div class=\"collection-card__art collection-card__art--5\" aria-hidden=\"true\"></div>\n" +
|
|
660
|
+
" <div class=\"collection-card__art collection-card__art--5\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M86 36 L106 44 L106 64 L86 72 L66 64 L66 44 Z\" stroke=\"#732A8D\"/><path d=\"M66 44 L86 52 L106 44 M86 52 V72\" stroke=\"#732A8D\"/><path d=\"M70 56 L90 64 L90 88 L70 96 L50 88 L50 64 Z\"/><path d=\"M50 64 L70 72 L90 64 M70 72 V96\"/><path d=\"M70 72 V96\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
|
|
657
661
|
" <div class=\"collection-card__meta\">\n" +
|
|
658
662
|
" <h3>Bundles</h3>\n" +
|
|
659
663
|
" <p>Composite SKUs, atomic stock.</p>\n" +
|
|
660
664
|
" </div>\n" +
|
|
661
665
|
" </a>\n" +
|
|
662
666
|
" <a class=\"collection-card\" href=\"/search?q=gift\">\n" +
|
|
663
|
-
" <div class=\"collection-card__art collection-card__art--6\" aria-hidden=\"true\"></div>\n" +
|
|
667
|
+
" <div class=\"collection-card__art collection-card__art--6\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"48\" y=\"48\" width=\"64\" height=\"42\" rx=\"6\"/><path d=\"M80 48 V90\" stroke=\"#732A8D\"/><path d=\"M80 48 C71 36 56 39 62 49 C57 52 61 57 71 53 C76 51 80 50 80 48 Z\"/><path d=\"M80 48 C89 36 104 39 98 49 C103 52 99 57 89 53 C84 51 80 50 80 48 Z\"/><circle cx=\"80\" cy=\"48\" r=\"2.4\" fill=\"#AD38DB\" stroke=\"none\"/><rect x=\"56\" y=\"70\" width=\"11\" height=\"8\" rx=\"1.6\" stroke=\"currentColor\" stroke-width=\"1.8\"/></svg></div>\n" +
|
|
664
668
|
" <div class=\"collection-card__meta\">\n" +
|
|
665
669
|
" <h3>Gift cards</h3>\n" +
|
|
666
670
|
" <p>PQC-signed redemption codes.</p>\n" +
|
|
@@ -847,7 +851,7 @@ var SEARCH_HEADER =
|
|
|
847
851
|
var SEARCH_EMPTY =
|
|
848
852
|
"<section class=\"search-empty\">\n" +
|
|
849
853
|
" <div class=\"search-empty__inner\">\n" +
|
|
850
|
-
" <p class=\"search-empty__icon\" aria-hidden=\"true\"
|
|
854
|
+
" <p class=\"search-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"90\" cy=\"58\" r=\"30\"/><path d=\"M112 80 L132 100\"/><path d=\"M80 52 L86 58 L80 64\" stroke=\"currentColor\" stroke-width=\"2.4\"/><path d=\"M92 64 H102\" stroke=\"currentColor\" stroke-width=\"2.4\"/><circle cx=\"46\" cy=\"34\" r=\"2\" fill=\"#732A8D\" stroke=\"none\"/><circle cx=\"146\" cy=\"44\" r=\"2\" fill=\"#732A8D\" stroke=\"none\"/></svg></p>\n" +
|
|
851
855
|
" <h2>{{heading}}</h2>\n" +
|
|
852
856
|
" <p>{{copy}}</p>\n" +
|
|
853
857
|
" {{clear_link}}\n" +
|
|
@@ -1229,9 +1233,8 @@ function _buildPdpGallery(product, media, assetPrefix) {
|
|
|
1229
1233
|
var prefix = assetPrefix || "/assets/";
|
|
1230
1234
|
function _escAttr(s) { return b.template.escapeHtml(s); }
|
|
1231
1235
|
if (!media || media.length === 0) {
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
"<span class=\"pdp__media-mark\">" + _escAttr(initial) + "</span>" +
|
|
1236
|
+
return "<figure class=\"pdp__media pdp__media--placeholder\" aria-hidden=\"true\">" +
|
|
1237
|
+
"<svg class=\"media-ph__svg\" viewBox=\"0 0 240 240\" aria-hidden=\"true\"><rect width=\"240\" height=\"240\" fill=\"none\"/><g stroke=\"currentColor\" stroke-opacity=\"0.10\" stroke-width=\"1\"><path d=\"M0 40 H240 M0 80 H240 M0 120 H240 M0 160 H240 M0 200 H240 M40 0 V240 M80 0 V240 M120 0 V240 M160 0 V240 M200 0 V240\"/></g><g fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M120 64 L162 80 L162 122 C162 152 144 168 120 178 C96 168 78 152 78 122 L78 80 Z\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M120 92 L146 105 L146 134 L120 147 L94 134 L94 105 Z\"/><path d=\"M94 105 L120 118 L146 105 M120 118 V147\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M107 101 L112 105 L107 109\" stroke=\"currentColor\" stroke-width=\"2.4\"/><path d=\"M116 110 H128\" stroke=\"currentColor\" stroke-width=\"2.4\"/></g><text x=\"120\" y=\"208\" text-anchor=\"middle\" font-family=\"ui-monospace,Menlo,Consolas,monospace\" font-size=\"12\" letter-spacing=\"2\" fill=\"#6b6b78\">no image yet</text></svg>" +
|
|
1235
1238
|
"</figure>" +
|
|
1236
1239
|
"<ul class=\"pdp__thumbs\" aria-hidden=\"true\">" +
|
|
1237
1240
|
"<li class=\"is-active\"></li><li></li><li></li><li></li>" +
|
|
@@ -1715,7 +1718,7 @@ function renderWishlist(opts) {
|
|
|
1715
1718
|
var inner = rowsHtml
|
|
1716
1719
|
? "<ul class=\"wishlist-list\">" + rowsHtml + "</ul>"
|
|
1717
1720
|
: "<div class=\"account-empty\">" +
|
|
1718
|
-
"<p class=\"account-empty__icon\" aria-hidden=\"true\"
|
|
1721
|
+
"<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M100 104 C70 82 60 64 60 50 C60 38 70 32 80 34 C88 35 96 42 100 50 C104 42 112 35 120 34 C130 32 140 38 140 50 C140 64 130 82 100 104 Z\"/><path d=\"M100 90 C82 76 76 64 76 54\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/><path d=\"M132 86 V98 M126 92 H138\" stroke=\"#732A8D\" stroke-width=\"2\"/></svg></p>" +
|
|
1719
1722
|
"<p class=\"account-empty__lede\">You haven't saved anything yet. Browse the shop and tap <strong>Save to wishlist</strong> on products you want to keep an eye on.</p>" +
|
|
1720
1723
|
"<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
|
|
1721
1724
|
"</div>";
|
|
@@ -1964,7 +1967,7 @@ function renderSaved(opts) {
|
|
|
1964
1967
|
var inner = rowsHtml
|
|
1965
1968
|
? "<ul class=\"saved-list\">" + rowsHtml + "</ul>"
|
|
1966
1969
|
: "<div class=\"account-empty\">" +
|
|
1967
|
-
"<p class=\"account-empty__icon\" aria-hidden=\"true\"
|
|
1970
|
+
"<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M78 32 H122 V104 L100 88 L78 104 Z\"/><path d=\"M90 58 H110\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/><circle cx=\"138\" cy=\"46\" r=\"12\" stroke=\"#732A8D\" stroke-width=\"2\"/><path d=\"M138 40 V46 L143 49\" stroke=\"#732A8D\" stroke-width=\"2\"/></svg></p>" +
|
|
1968
1971
|
"<p class=\"account-empty__lede\">Nothing saved for later. Use <strong>Save for later</strong> on a cart item to move it here without losing it.</p>" +
|
|
1969
1972
|
"<a class=\"btn-secondary\" href=\"/cart\">View your cart →</a>" +
|
|
1970
1973
|
"</div>";
|
|
@@ -2070,7 +2073,7 @@ function renderAddresses(opts) {
|
|
|
2070
2073
|
var listHtml = rowsHtml
|
|
2071
2074
|
? "<ul class=\"address-list\">" + rowsHtml + "</ul>"
|
|
2072
2075
|
: "<div class=\"account-empty\">" +
|
|
2073
|
-
"<p class=\"account-empty__icon\" aria-hidden=\"true\"
|
|
2076
|
+
"<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M100 110 C84 90 74 76 74 60 A26 26 0 0 1 126 60 C126 76 116 90 100 110 Z\"/><path d=\"M88 64 L100 52 L112 64 M91 61 V74 H109 V61\" stroke=\"#732A8D\" stroke-width=\"2.2\"/><path d=\"M97 74 V67 H103 V74\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></p>" +
|
|
2074
2077
|
"<p class=\"account-empty__lede\">No saved addresses yet. Add one below to speed up checkout.</p>" +
|
|
2075
2078
|
"</div>";
|
|
2076
2079
|
var notice = opts.notice
|
|
@@ -2464,7 +2467,7 @@ function renderReturns(opts) {
|
|
|
2464
2467
|
var inner = rowsHtml
|
|
2465
2468
|
? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
|
|
2466
2469
|
: "<div class=\"account-empty\">" +
|
|
2467
|
-
"<p class=\"account-empty__icon\" aria-hidden=\"true\"
|
|
2470
|
+
"<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M66 64 L100 50 L134 64 L100 78 Z\"/><path d=\"M66 64 V92 L100 106 L134 92 V64 M100 78 V106\"/><path d=\"M100 36 A20 20 0 0 1 120 56\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M100 28 L100 40 L110 38\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M88 70 H112\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/></svg></p>" +
|
|
2468
2471
|
"<p class=\"account-empty__lede\">No returns yet. Start one from an order in your account.</p>" +
|
|
2469
2472
|
"<a class=\"btn-secondary\" href=\"/account/orders\">View your orders →</a>" +
|
|
2470
2473
|
"</div>";
|
|
@@ -2891,7 +2894,7 @@ function renderAccountSubscriptions(opts) {
|
|
|
2891
2894
|
var inner = rowsHtml
|
|
2892
2895
|
? note + "<ul class=\"subscription-list\">" + rowsHtml + "</ul>"
|
|
2893
2896
|
: "<div class=\"account-empty\">" +
|
|
2894
|
-
"<p class=\"account-empty__icon\" aria-hidden=\"true\"
|
|
2897
|
+
"<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M74 30 H126 V104 L118 98 L110 104 L100 98 L90 104 L82 98 L74 104 Z\"/><path d=\"M86 48 H114 M86 62 H114\" stroke=\"#732A8D\" stroke-width=\"2.2\"/><path d=\"M86 76 H106\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/></svg></p>" +
|
|
2895
2898
|
"<p class=\"account-empty__lede\">You have no active subscriptions.</p>" +
|
|
2896
2899
|
"<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
|
|
2897
2900
|
"</div>";
|
|
@@ -3664,7 +3667,7 @@ var CART_EMPTY_PAGE =
|
|
|
3664
3667
|
" </nav>\n" +
|
|
3665
3668
|
" <div class=\"cart-empty\">\n" +
|
|
3666
3669
|
" <div class=\"cart-empty__card\">\n" +
|
|
3667
|
-
" <p class=\"cart-empty__icon\" aria-hidden=\"true\"
|
|
3670
|
+
" <p class=\"cart-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M52 50 H66 L74 90 H128 L138 58 H72\"/><path d=\"M46 44 H56\" stroke=\"#732A8D\"/><circle cx=\"82\" cy=\"106\" r=\"7\"/><circle cx=\"120\" cy=\"106\" r=\"7\"/><path d=\"M84 72 H122\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/><path d=\"M150 38 L150 50 M144 44 L156 44\" stroke=\"#732A8D\" stroke-width=\"2\"/></svg></p>\n" +
|
|
3668
3671
|
" <p class=\"eyebrow cart-empty__eyebrow\">Cart</p>\n" +
|
|
3669
3672
|
" <h1 class=\"cart-empty__title\">Your cart is empty</h1>\n" +
|
|
3670
3673
|
" <p class=\"cart-empty__lede\">Browse the catalog and the products you add show up here. Items hold their price at add-time, not at checkout.</p>\n" +
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.13.
|
|
7
|
-
"tag": "v0.13.
|
|
6
|
+
"version": "0.13.14",
|
|
7
|
+
"tag": "v0.13.14",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.14 (2026-05-27) — **DNSSEC chain validation now bounds KeyTrap (CVE-2023-50387) amplification with hard caps.** 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. **Security:** *`verifyChain` caps colliding-key fan-out and total signature validations (KeyTrap / CVE-2023-50387)* — 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. · *Domain-name octet cap + lower NSEC3 iteration ceiling* — 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).
|
|
12
|
+
|
|
11
13
|
- v0.13.13 (2026-05-27) — **Archive extraction-path verification now refuses Windows reserved names, NTFS data streams, and trailing-dot/space per segment.** b.guardFilename.verifyExtractionPath (the per-entry gate b.archive.read.zip.extract / b.safeArchive run on every extracted file) checked traversal, absolute paths, drive-letter and UNC prefixes, null bytes, PATH_MAX overflow, and realpath containment — but not the per-segment Windows write-target hazards the disk validate / sanitize paths already reject. An archive entry named CON, NUL.txt, subdir/LPT1, file.txt:hidden, or secret.txt. stayed inside the extraction root, so the containment and realpath checks passed it, yet on Windows it would resolve to a device, write a hidden NTFS stream, or (after Windows strips the trailing dot/space) overwrite a sibling file. These are now refused: any path segment that collides with a Windows reserved device name, uses NTFS alternate-data-stream syntax (name:stream), or carries a trailing dot or leading/trailing whitespace. The checks are platform-unconditional — a verifier running on Linux still refuses names that are only dangerous on the Windows host that ultimately extracts the archive — with a per-check opt-out (reservedNamePolicy / adsPolicy / leadingTrailingPolicy: "allow") for Linux-only targets. **Security:** *`verifyExtractionPath` refuses per-segment Windows extraction hazards (reserved names / NTFS ADS / trailing dot-space)* — Closes a within-root write-target-redirection gap: an extracted entry could stay inside the destination yet, on Windows, resolve to a device (`CON` / `NUL` / `COM1` / `LPT1`), write a hidden alternate data stream (`file.txt:payload`), or overwrite a sibling after Windows strips a trailing dot/space (`config.`). The verification gate now rejects all three per path segment. Refusal is platform-unconditional (the verifier may run on a different OS than the extractor); set `reservedNamePolicy` / `adsPolicy` / `leadingTrailingPolicy` to `"allow"` to opt a check out on a Linux-only target. Single-entry, name-only residuals — 8.3 short-name aliasing, case-insensitive cross-entry collisions, and archive symlink/hardlink entry-target validation — remain the extract orchestrator's responsibility (it owns the case-folded seen-set and the link-target gate).
|
|
12
14
|
|
|
13
15
|
- v0.13.12 (2026-05-27) — **Inbound MX listener now runs the connection-level gate cascade it documented — HELO identity, DNS blocklist, and greylisting.** b.mail.server.mx.create documented helo / rbl / greylist gate options, but the listener never invoked them — an operator who wired them got silent acceptance of mail those gates would have rejected. They are now wired into the live SMTP state machine: the HELO-identity gate evaluates at HELO/EHLO and refuses a spoofed or malformed identity with 550; the DNS-blocklist gate evaluates the connecting IP once per connection and refuses a listed source with 554; the greylisting gate defers a first-seen (ip, sender, recipient) tuple with a 450 tempfail so legitimate senders retry and pass. Each gate is skipped when the operator doesn't wire it. Because these gates do DNS and store lookups, the per-connection command pump was reworked to process commands asynchronously and strictly in arrival order, so pipelined commands (RFC 2920) cannot overtake a gate still resolving and the existing SMTP-smuggling and STARTTLS-stripping defenses are unchanged. The message-authentication gate (SPF/DKIM/DMARC alignment via b.guardEnvelope) needs the inbound SPF + DKIM verification results as inputs; that inbound-auth pipeline lands as a follow-up, and the documentation no longer implies that gate is active today. **Added:** *HELO-identity / RBL / greylist gates wired into `b.mail.server.mx`* — When wired, `opts.helo` (FCrDNS / HELO-shape / self-name checks) refuses a bad HELO identity at HELO/EHLO with 550; `opts.rbl` refuses a connecting IP found on a DNS blocklist with 554 (evaluated once per connection); `opts.greylist` defers a first-seen (ip, sender, recipient) tuple with 450 4.7.1. Their verdicts surface on the `rcpt_to` event (`rblListed`, `greylist`) and the `helo` event (`heloVerdict`), with dedicated `helo_gate_refused` / `rbl_refused` / `greylist_deferred` audit events. A gate the operator doesn't supply is skipped, never synthesized. **Changed:** *MX command pump processes commands asynchronously and in arrival order* — Gate evaluation involves DNS and store lookups, so the per-connection command pump now awaits each command before the next. Pipelined commands are serialized so a gate resolving cannot let a later command answer ahead of an earlier one; reply ordering, the bare-LF SMTP-smuggling refusal, and the STARTTLS-stripping defense are unchanged. No change to the listener's external behaviour when no gates are wired. **Deprecated:** *SPF/DKIM/DMARC-alignment gate documentation corrected to match what is active* — The `envelope` (SPF/DKIM/DMARC alignment) and `dmarc` gate options were documented as wireable but require inbound SPF + DKIM verification results the listener does not yet produce. They are removed from the documented option set until the inbound-authentication pipeline (composing `b.mail.spf` + `b.mail.dmarc` + DKIM verification) lands; run those checks on the delivered message via the agent handoff in the meantime.
|
|
@@ -352,7 +352,7 @@ This is the minimum-viable security posture for a production deployment. The fra
|
|
|
352
352
|
- [ ] At boot, before any outbound socket opens: call `b.network.bootFromEnv({ env: process.env, audit: b.audit })` so operator-supplied NTP / DNS / proxy / DPI-trust / TCP socket settings (`BLAMEJS_NTP_*`, `BLAMEJS_DNS_*`, `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, `BLAMEJS_EXTRA_CA_CERTS`, `BLAMEJS_SOCKET_*`) apply uniformly
|
|
353
353
|
- [ ] If the deployment sits behind a deep-packet-inspection proxy with its own re-signing CA: install the CA via `b.network.tls.addCa("/path/to/corp-ca.pem", { label: "corp-mitm" })` and pass `allowDpiTrust: true` to `b.security.assertProduction` — every CA addition audits with subject + fingerprint so a forensic review can reconstruct the trust path
|
|
354
354
|
- [ ] For authenticated time (HIPAA / PCI / FIPS shops): use `b.network.ntp.nts.query({ host: ntsKeServer })` (RFC 8915) instead of plain SNTP; set `BLAMEJS_NTS_REQUIRE=1` to fail closed on negotiation failure
|
|
355
|
-
- [ ] When a DNS answer drives a trust decision (DANE / TLSA pinning, SSHFP, CAA enforcement, OPENPGPKEY lookup) and the upstream resolver isn't itself trusted: verify the answer's DNSSEC signature with `b.network.dns.dnssec.verifyRrset(...)` rather than trusting the resolver's AD bit — an on-path or compromised resolver can set AD on a forged answer, but cannot forge the RRSIG. Validate the whole delegation chain root→TLD→zone with `b.network.dns.dnssec.verifyChain(...)` (default-pinned to the IANA root KSKs, or `trustAnchors` for a private root) so trust is anchored, not borrowed from the resolver. For a negative answer that drives a fail-closed decision (an allowlist lookup, a revocation check), verify the NSEC / NSEC3 proof with `b.network.dns.dnssec.verifyDenial(...)` so a forged NXDOMAIN cannot suppress a record; keep the default Opt-Out refusal unless the zone's opt-out spans are acceptable for that decision. For DANE / TLSA, once the TLSA RRset is DNSSEC-verified, pin the peer certificate with `b.network.dns.dane.matchCertificate(...)` — a DANE-EE(3) match authenticates the key with no public CA, while PKIX usages are flagged as still needing PKIX
|
|
355
|
+
- [ ] When a DNS answer drives a trust decision (DANE / TLSA pinning, SSHFP, CAA enforcement, OPENPGPKEY lookup) and the upstream resolver isn't itself trusted: verify the answer's DNSSEC signature with `b.network.dns.dnssec.verifyRrset(...)` rather than trusting the resolver's AD bit — an on-path or compromised resolver can set AD on a forged answer, but cannot forge the RRSIG. Validate the whole delegation chain root→TLD→zone with `b.network.dns.dnssec.verifyChain(...)` (default-pinned to the IANA root KSKs, or `trustAnchors` for a private root) so trust is anchored, not borrowed from the resolver. `verifyChain` bounds KeyTrap (CVE-2023-50387) amplification with non-configurable caps (≤4 same-tag candidate keys per RRSIG, ≤64 DNSKEYs/zone, ≤16 DS/delegation, ≤128 chain links, and a signature-validation budget that scales with chain depth so deep delegations validate while bounded collisions stay bounded) and caps NSEC3 iterations at 150 (CVE-2023-50868) — a hostile zone is refused, not allowed to exhaust CPU. For a negative answer that drives a fail-closed decision (an allowlist lookup, a revocation check), verify the NSEC / NSEC3 proof with `b.network.dns.dnssec.verifyDenial(...)` so a forged NXDOMAIN cannot suppress a record; keep the default Opt-Out refusal unless the zone's opt-out spans are acceptable for that decision. For DANE / TLSA, once the TLSA RRset is DNSSEC-verified, pin the peer certificate with `b.network.dns.dane.matchCertificate(...)` — a DANE-EE(3) match authenticates the key with no public CA, while PKIX usages are flagged as still needing PKIX
|
|
356
356
|
- [ ] At boot in production: call `await b.security.assertProduction({ vault: "wrapped", dbAtRest: "encrypted", auditSigning: "wrapped", ntpStrict: true, requireEnv: ["BLAMEJS_VAULT_PASSPHRASE"], dataDir: "./data" })` to refuse to start on weak posture instead of warning
|
|
357
357
|
- [ ] At boot: call `await b.configDrift.create({ dataDir, audit }).checkpoint({ allowedOrigins, csp, vaultMode, ... })` so the next boot detects + audits any silent runtime config change
|
|
358
358
|
- [ ] At boot, before any listener opens: call `b.configDrift.verifyVendorIntegrity({ manifestPath: "./lib/vendor/MANIFEST.json", audit: b.audit })` so a tampered `lib/vendor/*.cjs` artifact aborts start instead of running with a swapped crypto bundle
|
|
@@ -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
|
+
}
|
|
@@ -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 }); }
|
|
@@ -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();
|
package/package.json
CHANGED