@blamejs/blamejs-shop 0.1.7 → 0.1.8
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 +2 -0
- package/README.md +1 -1
- package/lib/admin.js +272 -33
- package/lib/order.js +70 -12
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/SECURITY.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +10 -2
- package/lib/vendor/blamejs/lib/network-dnssec.js +158 -7
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.50.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +13 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dnssec.test.js +121 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.8 (2026-05-25) — **Admin console — an orders screen with full lifecycle control.** Orders join the admin console. `/admin/orders` lists recent orders newest-first with one-click status filters, and each order opens to its line items, totals, shipping address, and payment reference. From the detail page an operator drives the order through its lifecycle — mark paid, start fulfilment, mark shipped, mark delivered, cancel, refund — with only the moves that are legal from the current status offered as buttons; an illegal move is refused with a notice rather than an error. Refund moves money, so the console Refund button issues a real payment-provider refund (and only appears when a provider is wired and the order has a captured intent) before the order advances to refunded. As with Products, every path content-negotiates: a bearer-token client gets the JSON API unchanged, a signed-in browser gets the HTML console. **Added:** *Orders management screen* — `/admin/orders` renders recent orders as a table (order, status, item count, total, placed date) with status-filter chips, when opened in a signed-in browser; the same path serves a new JSON list to a bearer-token client. Each order links to a detail page showing its line items, subtotal / tax / shipping / total, shipping address, and linked payment intent, with the order's legal next transitions as action buttons that post back and redirect (PRG). The Refund button posts to the payment-refund flow — it issues the provider refund and then advances the order, so a console refund never marks an order refunded without moving the money; it appears only when a payment provider is wired and the order has a captured intent (partial refunds remain on the JSON API). A bad or unknown order id renders a 404 page, never a 500; an illegal or failed action redirects back with a notice and leaves the order unchanged. · *order.listRecent + order.transitionsFrom* — `order.listRecent({ limit, status })` returns recent orders across all customers (guest orders included), newest-first, optionally filtered to one status, with line items and shipping hydrated. `order.transitionsFrom(status)` returns the lifecycle moves legal from a given status as `{ on, to, label }` — both derived from a single order-FSM edge list, so the console actions stay in lockstep with the state machine. **Changed:** *Console nav gains Orders; dashboard orders link through* — The signed-in admin nav now includes Orders alongside Home, Dashboard, Products, Integrations, and Setup. The dashboard's recent-orders list links each row to its order detail.
|
|
12
|
+
|
|
11
13
|
- v0.1.7 (2026-05-25) — **Admin console — persistent navigation and a products screen.** The admin is now a cohesive, navigable web console rather than a set of disconnected pages. A persistent nav (Home, Dashboard, Products, Integrations, Setup) runs across every signed-in page, and Products is the first full management screen: browse the catalog, create a product, and archive or restore one — all from the browser. The JSON API is unchanged: an endpoint serves the HTML console to a signed-in browser and JSON to a bearer-token client, so tooling keeps working exactly as before. **Added:** *Console navigation* — Every signed-in admin page shares a top nav with the current section highlighted, so the dashboard, products, integrations, and setup wizard are one console. The shell is the same brand-matched layout as the dashboard. · *Products management screen* — `/admin/products` renders the catalog as a table (title, slug, status) with Archive / Restore actions and a New-product form, when opened in a signed-in browser. The same path returns the existing JSON list to a bearer-token client; create, archive, and restore content-negotiate the same way (browser form → redirect; bad input re-renders with a notice, never a 500). The product create / archive / restore JSON API is unchanged.
|
|
12
14
|
|
|
13
15
|
- v0.1.6 (2026-05-25) — **Claim past guest orders when a shopper signs in.** A shopper who checked out as a guest and later signs in with a provider-verified email now finds those past orders attached to their account. Checkout records a hash of the buyer's email on the order; on Google sign-in, orders placed under that same email — and not yet owned by anyone — are claimed into the account. Linking happens only on an email the identity provider verified, never on an unverified one, so it can't be used to steal another shopper's order history. **Added:** *Guest-order reconciliation on sign-in* — Orders now carry a `customer_email_hash` (migration 0206), written at checkout from the buyer's email with the same key the customers table uses. On a verified Google sign-in, `order.linkGuestOrdersByEmailHash` claims every ownerless order under that email into the account — so prior guest purchases appear in /account and satisfy customer-scoped checks (e.g. verified-buyer reviews). Only ownerless orders are touched (an order already attached to a customer is never reassigned), and only a provider-verified email triggers a link.
|
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
72
72
|
| **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
|
|
73
73
|
| **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. |
|
|
74
74
|
| **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
|
|
75
|
-
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), a guided **setup wizard** at `/admin/setup`
|
|
75
|
+
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel, refund) through the order FSM. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. |
|
|
76
76
|
| **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
|
|
77
77
|
| **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
|
|
78
78
|
|
package/lib/admin.js
CHANGED
|
@@ -599,50 +599,152 @@ function mount(router, deps) {
|
|
|
599
599
|
|
|
600
600
|
// ---- orders ---------------------------------------------------------
|
|
601
601
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
602
|
+
// Recent orders across all customers. Bearer → no list endpoint existed
|
|
603
|
+
// before, so this adds one (JSON); a signed-in browser gets the console.
|
|
604
|
+
router.get("/admin/orders", _pageOrApi(true,
|
|
605
|
+
R(async function (req, res) {
|
|
606
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
607
|
+
var status = url && url.searchParams.get("status");
|
|
608
|
+
var limitS = url && url.searchParams.get("limit");
|
|
609
|
+
var limit = limitS == null ? 50 : parseInt(limitS, 10);
|
|
610
|
+
var list = await order.listRecent({ status: status || undefined, limit: limit });
|
|
611
|
+
_json(res, 200, list);
|
|
612
|
+
}),
|
|
613
|
+
async function (req, res) {
|
|
614
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
615
|
+
var statusRaw = url && url.searchParams.get("status");
|
|
616
|
+
// A bad ?status= filter falls back to "all" rather than erroring the
|
|
617
|
+
// page — the operator just sees everything, which is a safe default.
|
|
618
|
+
var status = null, notice = null;
|
|
619
|
+
if (statusRaw) {
|
|
620
|
+
try { await order.listRecent({ status: statusRaw, limit: 1 }); status = statusRaw; }
|
|
621
|
+
catch (_e) { notice = "Unknown status filter — showing all orders."; }
|
|
622
|
+
}
|
|
623
|
+
var list = await order.listRecent({ status: status || undefined, limit: 100 });
|
|
624
|
+
_sendHtml(res, 200, renderAdminOrders({
|
|
625
|
+
shop_name: deps.shop_name, orders: list.rows || [],
|
|
626
|
+
status: status, notice: notice,
|
|
627
|
+
}));
|
|
628
|
+
},
|
|
629
|
+
));
|
|
617
630
|
|
|
618
|
-
|
|
619
|
-
|
|
631
|
+
router.get("/admin/orders/:id", _pageOrApi(true,
|
|
632
|
+
R(async function (req, res) {
|
|
620
633
|
var o = await order.get(req.params.id);
|
|
621
634
|
if (!o) return _problem(res, 404, "order-not-found");
|
|
622
|
-
|
|
635
|
+
_json(res, 200, o);
|
|
636
|
+
}),
|
|
637
|
+
async function (req, res) {
|
|
638
|
+
var o;
|
|
639
|
+
// A malformed id throws (defensive id reader) — render 404, not 500.
|
|
640
|
+
try { o = await order.get(req.params.id); }
|
|
641
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
642
|
+
if (!o) return _sendHtml(res, 404, renderAdminOrders({
|
|
643
|
+
shop_name: deps.shop_name, orders: [], notice: "Order not found.",
|
|
644
|
+
}));
|
|
645
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
646
|
+
_sendHtml(res, 200, renderAdminOrder({
|
|
647
|
+
shop_name: deps.shop_name,
|
|
648
|
+
order: o,
|
|
649
|
+
transitions: order.transitionsFrom(o.status),
|
|
650
|
+
// Refund moves money, so the console only offers it when a payment
|
|
651
|
+
// provider is wired AND the order has a captured intent to refund.
|
|
652
|
+
can_refund: !!(payment && o.payment_intent_id),
|
|
653
|
+
moved: url && url.searchParams.get("moved"),
|
|
654
|
+
notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this order." : null,
|
|
655
|
+
}));
|
|
656
|
+
},
|
|
657
|
+
));
|
|
658
|
+
|
|
659
|
+
router.post("/admin/orders/:id/transition", _pageOrApi(false,
|
|
660
|
+
W("order.transition", async function (req, res) {
|
|
623
661
|
var body = req.body || {};
|
|
624
|
-
|
|
625
|
-
var
|
|
662
|
+
if (!body.event) throw new TypeError("admin.order.transition: body.event required");
|
|
663
|
+
var o = await order.transition(req.params.id, body.event, { reason: body.reason, metadata: body.metadata });
|
|
664
|
+
_json(res, 200, o);
|
|
665
|
+
return o;
|
|
666
|
+
}),
|
|
667
|
+
async function (req, res) {
|
|
668
|
+
// Browser form → run the transition, then redirect back to the
|
|
669
|
+
// detail (PRG). A bad id (TypeError) or an FSM refusal (the move
|
|
670
|
+
// isn't legal from this status) surfaces as a notice, not a 500;
|
|
671
|
+
// any other failure propagates.
|
|
672
|
+
var id = req.params.id;
|
|
673
|
+
var event = (req.body || {}).event;
|
|
674
|
+
if (!event) return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
626
675
|
try {
|
|
627
|
-
|
|
628
|
-
payment_intent: o.payment_intent_id,
|
|
629
|
-
amount_minor: body.amount_minor || undefined,
|
|
630
|
-
reason: body.reason || undefined,
|
|
631
|
-
metadata: { order_id: o.id },
|
|
632
|
-
}, refundIdempotencyKey);
|
|
676
|
+
await order.transition(id, event, { reason: "admin:console" });
|
|
633
677
|
} catch (e) {
|
|
634
|
-
|
|
678
|
+
if (e instanceof TypeError || (e && e.code && /FSM|TRANSITION|GUARD/i.test(e.code))) {
|
|
679
|
+
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
680
|
+
}
|
|
681
|
+
throw e;
|
|
635
682
|
}
|
|
683
|
+
_b().audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.transition", outcome: "success", metadata: { id: id, event: event } });
|
|
684
|
+
_redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?moved=1");
|
|
685
|
+
},
|
|
686
|
+
));
|
|
687
|
+
|
|
688
|
+
// ---- refunds --------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
if (payment) {
|
|
691
|
+
// Issue the actual payment-provider refund, then advance the order
|
|
692
|
+
// FSM. Shared by the JSON API and the browser console so a console
|
|
693
|
+
// "Refund" moves the money first (never a bare state change — that
|
|
694
|
+
// would mark an order refunded with the customer never paid back).
|
|
695
|
+
async function _refundOrder(o, body) {
|
|
696
|
+
var refundIdempotencyKey = "refund:" + o.id + ":" + (body.idempotency_suffix || _b().uuid.v7());
|
|
697
|
+
var refund = await payment.refund({
|
|
698
|
+
payment_intent: o.payment_intent_id,
|
|
699
|
+
amount_minor: body.amount_minor || undefined,
|
|
700
|
+
reason: body.reason || undefined,
|
|
701
|
+
metadata: { order_id: o.id },
|
|
702
|
+
}, refundIdempotencyKey);
|
|
636
703
|
try {
|
|
637
704
|
await order.transition(o.id, "refund", {
|
|
638
705
|
reason: "admin:refund:" + (body.reason || "requested_by_customer"),
|
|
639
706
|
metadata: { stripe_refund_id: refund.id, amount_minor: refund.amount },
|
|
640
707
|
});
|
|
641
|
-
} catch (_e) { /* refund succeeded at
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
708
|
+
} catch (_e) { /* refund succeeded at the provider; transition refusal logged, surfaced via re-fetch */ }
|
|
709
|
+
return { refund: refund, order: await order.get(o.id) };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
router.post("/admin/orders/:id/refund", _pageOrApi(false,
|
|
713
|
+
W("order.refund", async function (req, res) {
|
|
714
|
+
var o = await order.get(req.params.id);
|
|
715
|
+
if (!o) return _problem(res, 404, "order-not-found");
|
|
716
|
+
if (!o.payment_intent_id) return _problem(res, 422, "no-payment-intent", "Order has no linked payment intent");
|
|
717
|
+
var result;
|
|
718
|
+
try {
|
|
719
|
+
result = await _refundOrder(o, req.body || {});
|
|
720
|
+
} catch (e) {
|
|
721
|
+
return _problem(res, 502, "stripe-refund-failed", (e && e.message) || String(e));
|
|
722
|
+
}
|
|
723
|
+
_json(res, 200, result);
|
|
724
|
+
return { id: o.id };
|
|
725
|
+
}),
|
|
726
|
+
async function (req, res) {
|
|
727
|
+
// Browser console: full refund (partial refunds stay on the JSON
|
|
728
|
+
// API via amount_minor), then PRG back to the detail. A bad id or
|
|
729
|
+
// missing payment intent surfaces as a notice, never a 500.
|
|
730
|
+
var id = req.params.id;
|
|
731
|
+
var o;
|
|
732
|
+
try { o = await order.get(id); }
|
|
733
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
734
|
+
if (!o || !o.payment_intent_id) {
|
|
735
|
+
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
await _refundOrder(o, { reason: "requested_by_customer" });
|
|
739
|
+
} catch (_e) {
|
|
740
|
+
// Provider refund failed — the order is untouched (the FSM
|
|
741
|
+
// transition only runs after a successful refund).
|
|
742
|
+
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
743
|
+
}
|
|
744
|
+
_b().audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.refund", outcome: "success", metadata: { id: id } });
|
|
745
|
+
_redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?moved=1");
|
|
746
|
+
},
|
|
747
|
+
));
|
|
646
748
|
}
|
|
647
749
|
|
|
648
750
|
// ---- reviews (moderation) -------------------------------------------
|
|
@@ -1232,6 +1334,15 @@ var DASHBOARD_LAYOUT =
|
|
|
1232
1334
|
" .btn:hover { background:var(--accent-d); border-color:var(--accent-d); }\n" +
|
|
1233
1335
|
" .btn--ghost { background:transparent; color:var(--ink); border-color:var(--ink); }\n" +
|
|
1234
1336
|
" .btn--ghost:hover { background:var(--ink); color:var(--paper); }\n" +
|
|
1337
|
+
" .btn--danger { background:transparent; color:var(--accent-d); border-color:var(--accent-d); }\n" +
|
|
1338
|
+
" .btn--danger:hover { background:var(--accent-d); color:var(--paper); }\n" +
|
|
1339
|
+
" .order-filters { display:flex; flex-wrap:wrap; gap:.5rem; margin-bottom:1.25rem; }\n" +
|
|
1340
|
+
" .chip { display:inline-block; padding:.3rem .8rem; border-radius:999px; border:1px solid var(--hair); color:var(--ink-2); text-decoration:none; font-size:.78rem; text-transform:capitalize; }\n" +
|
|
1341
|
+
" .chip:hover { border-color:var(--accent); }\n" +
|
|
1342
|
+
" .chip--on { background:var(--ink); color:var(--paper); border-color:var(--ink); }\n" +
|
|
1343
|
+
" .order-totals { width:100%; }\n" +
|
|
1344
|
+
" .order-totals td { padding:.3rem 0; }\n" +
|
|
1345
|
+
" .order-actions { display:flex; flex-wrap:wrap; gap:.6rem; }\n" +
|
|
1235
1346
|
" .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
|
|
1236
1347
|
" .nav-card { display:block; background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.4rem; text-decoration:none; color:var(--ink); }\n" +
|
|
1237
1348
|
" .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
|
|
@@ -1364,7 +1475,7 @@ function renderDashboard(opts) {
|
|
|
1364
1475
|
? recent.map(function (o) {
|
|
1365
1476
|
var statusClass = _htmlEscape(o.status);
|
|
1366
1477
|
return "<tr>" +
|
|
1367
|
-
"<td><
|
|
1478
|
+
"<td><a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(o.id) + "\">" + _htmlEscape(o.id.slice(0, 8)) + "</a></td>" +
|
|
1368
1479
|
"<td><span class=\"status-pill " + statusClass + "\">" + _htmlEscape(o.status) + "</span></td>" +
|
|
1369
1480
|
"<td class=\"num\">" + _htmlEscape(pricing.format(o.grand_total_minor, o.currency)) + "</td>" +
|
|
1370
1481
|
"</tr>";
|
|
@@ -1407,6 +1518,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
1407
1518
|
{ key: "home", href: "/admin", label: "Home" },
|
|
1408
1519
|
{ key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
|
|
1409
1520
|
{ key: "products", href: "/admin/products", label: "Products" },
|
|
1521
|
+
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
1410
1522
|
{ key: "integrations", href: "/admin/integrations", label: "Integrations" },
|
|
1411
1523
|
{ key: "setup", href: "/admin/setup", label: "Setup" },
|
|
1412
1524
|
];
|
|
@@ -1580,6 +1692,131 @@ function renderAdminProducts(opts) {
|
|
|
1580
1692
|
return _renderAdminShell(opts.shop_name, "Products", body, "products");
|
|
1581
1693
|
}
|
|
1582
1694
|
|
|
1695
|
+
// created_at / updated_at are epoch-ms numbers (order._now()); render a
|
|
1696
|
+
// short, locale-neutral date. Guards against a string or a bad value so a
|
|
1697
|
+
// malformed row never throws inside the template.
|
|
1698
|
+
function _fmtDate(v) {
|
|
1699
|
+
var n = typeof v === "number" ? v : Date.parse(v);
|
|
1700
|
+
if (!isFinite(n)) return "—";
|
|
1701
|
+
return new Date(n).toISOString().slice(0, 10);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// The status values an operator can filter the orders list by — drives the
|
|
1705
|
+
// filter chips. Kept in render-layer order (lifecycle, then terminal).
|
|
1706
|
+
var ORDER_STATUS_FILTERS = ["pending", "paid", "fulfilling", "shipped", "delivered", "refunded", "cancelled"];
|
|
1707
|
+
|
|
1708
|
+
function renderAdminOrders(opts) {
|
|
1709
|
+
opts = opts || {};
|
|
1710
|
+
var orders = opts.orders || [];
|
|
1711
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
1712
|
+
var active = opts.status || null;
|
|
1713
|
+
|
|
1714
|
+
var chips = "<div class=\"order-filters\">" +
|
|
1715
|
+
"<a class=\"chip" + (active ? "" : " chip--on") + "\" href=\"/admin/orders\">All</a>" +
|
|
1716
|
+
ORDER_STATUS_FILTERS.map(function (s) {
|
|
1717
|
+
return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/orders?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
|
|
1718
|
+
}).join("") +
|
|
1719
|
+
"</div>";
|
|
1720
|
+
|
|
1721
|
+
var rows = orders.map(function (o) {
|
|
1722
|
+
var items = (o.lines || []).reduce(function (n, l) { return n + (l.qty || 0); }, 0);
|
|
1723
|
+
return "<tr>" +
|
|
1724
|
+
"<td><a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(o.id) + "\">" + _htmlEscape(o.id.slice(0, 8)) + "</a></td>" +
|
|
1725
|
+
"<td><span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></td>" +
|
|
1726
|
+
"<td class=\"num\">" + _htmlEscape(String(items)) + "</td>" +
|
|
1727
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(o.grand_total_minor, o.currency)) + "</td>" +
|
|
1728
|
+
"<td>" + _htmlEscape(_fmtDate(o.created_at)) + "</td>" +
|
|
1729
|
+
"</tr>";
|
|
1730
|
+
}).join("");
|
|
1731
|
+
|
|
1732
|
+
var table = orders.length
|
|
1733
|
+
? "<div class=\"panel\"><table><thead><tr><th>Order</th><th>Status</th><th class=\"num\">Items</th><th class=\"num\">Total</th><th>Placed</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
1734
|
+
: "<p class=\"empty\">No orders" + (active ? " with status “" + _htmlEscape(active) + "”" : " yet") + ".</p>";
|
|
1735
|
+
|
|
1736
|
+
var body = "<section><h2>Orders</h2>" + notice + chips + table + "</section>";
|
|
1737
|
+
return _renderAdminShell(opts.shop_name, "Orders", body, "orders");
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
function renderAdminOrder(opts) {
|
|
1741
|
+
opts = opts || {};
|
|
1742
|
+
var o = opts.order;
|
|
1743
|
+
var transitions = opts.transitions || [];
|
|
1744
|
+
var moved = opts.moved ? "<div class=\"banner banner--ok\">Order updated.</div>" : "";
|
|
1745
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
1746
|
+
|
|
1747
|
+
var lineRows = (o.lines || []).map(function (l) {
|
|
1748
|
+
return "<tr>" +
|
|
1749
|
+
"<td>" + _htmlEscape(l.sku) + "</td>" +
|
|
1750
|
+
"<td class=\"num\">" + _htmlEscape(String(l.qty)) + "</td>" +
|
|
1751
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(l.unit_amount_minor, l.unit_currency)) + "</td>" +
|
|
1752
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(l.line_total_minor, l.unit_currency)) + "</td>" +
|
|
1753
|
+
"</tr>";
|
|
1754
|
+
}).join("");
|
|
1755
|
+
var linesTable = (o.lines && o.lines.length)
|
|
1756
|
+
? "<table><thead><tr><th>SKU</th><th class=\"num\">Qty</th><th class=\"num\">Unit</th><th class=\"num\">Line</th></tr></thead><tbody>" + lineRows + "</tbody></table>"
|
|
1757
|
+
: "<p class=\"empty\">No line items recorded.</p>";
|
|
1758
|
+
|
|
1759
|
+
function _total(label, minor, strong) {
|
|
1760
|
+
return "<tr><td>" + _htmlEscape(label) + "</td><td class=\"num\">" +
|
|
1761
|
+
(strong ? "<strong>" : "") + _htmlEscape(pricing.format(minor, o.currency)) + (strong ? "</strong>" : "") +
|
|
1762
|
+
"</td></tr>";
|
|
1763
|
+
}
|
|
1764
|
+
var totals = "<table class=\"order-totals\"><tbody>" +
|
|
1765
|
+
_total("Subtotal", o.subtotal_minor, false) +
|
|
1766
|
+
(o.discount_minor ? _total("Discount", -o.discount_minor, false) : "") +
|
|
1767
|
+
_total("Tax", o.tax_minor, false) +
|
|
1768
|
+
_total("Shipping", o.shipping_minor, false) +
|
|
1769
|
+
_total("Total", o.grand_total_minor, true) +
|
|
1770
|
+
"</tbody></table>";
|
|
1771
|
+
|
|
1772
|
+
var ship = o.ship_to || {};
|
|
1773
|
+
var shipLines = [ship.name, ship.line1, ship.line2,
|
|
1774
|
+
[ship.city, ship.region, ship.postal_code].filter(Boolean).join(", "), ship.country]
|
|
1775
|
+
.filter(Boolean).map(function (s) { return _htmlEscape(String(s)); }).join("<br>");
|
|
1776
|
+
|
|
1777
|
+
// One form per legal next transition. `refund` is special: it moves
|
|
1778
|
+
// money, so it posts to the payment-refund endpoint (which issues the
|
|
1779
|
+
// provider refund THEN advances the FSM) rather than the bare
|
|
1780
|
+
// state-transition endpoint — and only when there's a captured payment
|
|
1781
|
+
// to refund. Every other move posts to /transition. A terminal status
|
|
1782
|
+
// (empty list) shows a note instead of buttons.
|
|
1783
|
+
var actionForms = transitions.map(function (t) {
|
|
1784
|
+
if (t.on === "refund") {
|
|
1785
|
+
if (!opts.can_refund) return ""; // no payment intent — nothing to refund here
|
|
1786
|
+
return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund\" style=\"display:inline;\">" +
|
|
1787
|
+
"<button class=\"btn btn--danger\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
|
|
1788
|
+
"</form>";
|
|
1789
|
+
}
|
|
1790
|
+
var danger = (t.on === "cancel");
|
|
1791
|
+
return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/transition\" style=\"display:inline;\">" +
|
|
1792
|
+
"<input type=\"hidden\" name=\"event\" value=\"" + _htmlEscape(t.on) + "\">" +
|
|
1793
|
+
"<button class=\"btn" + (danger ? " btn--danger" : "") + "\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
|
|
1794
|
+
"</form>";
|
|
1795
|
+
}).filter(Boolean).join(" ");
|
|
1796
|
+
var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
|
|
1797
|
+
|
|
1798
|
+
var body =
|
|
1799
|
+
"<section style=\"max-width:48rem;\">" +
|
|
1800
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">← Orders</a></div>" +
|
|
1801
|
+
"<h2>Order <code class=\"order-id\">" + _htmlEscape(o.id.slice(0, 8)) + "</code> " +
|
|
1802
|
+
"<span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></h2>" +
|
|
1803
|
+
"<p class=\"meta\">Placed " + _htmlEscape(_fmtDate(o.created_at)) + " · last updated " + _htmlEscape(_fmtDate(o.updated_at)) +
|
|
1804
|
+
(o.payment_intent_id ? " · payment <code class=\"order-id\">" + _htmlEscape(o.payment_intent_id) + "</code>" : "") + "</p>" +
|
|
1805
|
+
moved + notice +
|
|
1806
|
+
"<div class=\"two-col\">" +
|
|
1807
|
+
"<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Items</h3>" + linesTable + "</div>" +
|
|
1808
|
+
"<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Ship to</h3>" +
|
|
1809
|
+
(shipLines || "<span class=\"meta\">No shipping address.</span>") +
|
|
1810
|
+
"<h3 style=\"font-size:.95rem; margin:1.25rem 0 .75rem;\">Totals</h3>" + totals +
|
|
1811
|
+
"</div>" +
|
|
1812
|
+
"</div>" +
|
|
1813
|
+
"<div class=\"panel\" style=\"margin-top:1.5rem;\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Actions</h3>" +
|
|
1814
|
+
"<div class=\"order-actions\">" + actions + "</div>" +
|
|
1815
|
+
"</div>" +
|
|
1816
|
+
"</section>";
|
|
1817
|
+
return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders");
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1583
1820
|
module.exports = {
|
|
1584
1821
|
mount: mount,
|
|
1585
1822
|
AUDIT_NAMESPACE: AUDIT_NAMESPACE,
|
|
@@ -1589,4 +1826,6 @@ module.exports = {
|
|
|
1589
1826
|
renderAdminSetup: renderAdminSetup,
|
|
1590
1827
|
renderAdminIntegrations: renderAdminIntegrations,
|
|
1591
1828
|
renderAdminProducts: renderAdminProducts,
|
|
1829
|
+
renderAdminOrders: renderAdminOrders,
|
|
1830
|
+
renderAdminOrder: renderAdminOrder,
|
|
1592
1831
|
};
|
package/lib/order.js
CHANGED
|
@@ -45,6 +45,23 @@ function _b() {
|
|
|
45
45
|
// ---- FSM definition -----------------------------------------------------
|
|
46
46
|
|
|
47
47
|
var _orderFsm = null;
|
|
48
|
+
// The order lifecycle, as edges of the FSM. Single source of truth: the
|
|
49
|
+
// FSM definition below is built from it, and the operator console derives
|
|
50
|
+
// the available actions for an order from it (so a new edge here lights up
|
|
51
|
+
// a button in /admin/orders with no separate map to keep in sync).
|
|
52
|
+
var ORDER_TRANSITIONS = Object.freeze([
|
|
53
|
+
{ from: "pending", to: "paid", on: "mark_paid", label: "Mark paid" },
|
|
54
|
+
{ from: "paid", to: "fulfilling", on: "start_fulfillment", label: "Start fulfilment" },
|
|
55
|
+
{ from: "fulfilling", to: "shipped", on: "mark_shipped", label: "Mark shipped" },
|
|
56
|
+
{ from: "shipped", to: "delivered", on: "mark_delivered", label: "Mark delivered" },
|
|
57
|
+
{ from: "pending", to: "cancelled", on: "cancel", label: "Cancel" },
|
|
58
|
+
{ from: "paid", to: "cancelled", on: "cancel", label: "Cancel" },
|
|
59
|
+
{ from: "paid", to: "refunded", on: "refund", label: "Refund" },
|
|
60
|
+
{ from: "fulfilling", to: "refunded", on: "refund", label: "Refund" },
|
|
61
|
+
{ from: "shipped", to: "refunded", on: "refund", label: "Refund" },
|
|
62
|
+
{ from: "delivered", to: "refunded", on: "refund", label: "Refund" },
|
|
63
|
+
]);
|
|
64
|
+
|
|
48
65
|
function _getOrderFsm() {
|
|
49
66
|
if (_orderFsm) return _orderFsm;
|
|
50
67
|
// b.fsm emits audit events under the 'fsm' namespace —
|
|
@@ -63,24 +80,21 @@ function _getOrderFsm() {
|
|
|
63
80
|
refunded: {},
|
|
64
81
|
cancelled: {},
|
|
65
82
|
},
|
|
66
|
-
transitions:
|
|
67
|
-
{ from:
|
|
68
|
-
|
|
69
|
-
{ from: "fulfilling", to: "shipped", on: "mark_shipped" },
|
|
70
|
-
{ from: "shipped", to: "delivered", on: "mark_delivered" },
|
|
71
|
-
{ from: "pending", to: "cancelled", on: "cancel" },
|
|
72
|
-
{ from: "paid", to: "cancelled", on: "cancel" },
|
|
73
|
-
{ from: "paid", to: "refunded", on: "refund" },
|
|
74
|
-
{ from: "fulfilling", to: "refunded", on: "refund" },
|
|
75
|
-
{ from: "shipped", to: "refunded", on: "refund" },
|
|
76
|
-
{ from: "delivered", to: "refunded", on: "refund" },
|
|
77
|
-
],
|
|
83
|
+
transitions: ORDER_TRANSITIONS.map(function (t) {
|
|
84
|
+
return { from: t.from, to: t.to, on: t.on };
|
|
85
|
+
}),
|
|
78
86
|
});
|
|
79
87
|
return _orderFsm;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
var TERMINAL_STATES = Object.freeze(["refunded", "cancelled", "delivered"]);
|
|
83
91
|
|
|
92
|
+
// Every state the order FSM can occupy — the allowed values for the
|
|
93
|
+
// `status` filter on the operator-facing recent-orders list.
|
|
94
|
+
var ORDER_STATES = Object.freeze([
|
|
95
|
+
"pending", "paid", "fulfilling", "shipped", "delivered", "refunded", "cancelled",
|
|
96
|
+
]);
|
|
97
|
+
|
|
84
98
|
// Cursor key for listForCustomer — paginates by (updated_at DESC, id
|
|
85
99
|
// DESC) so a newly transitioned order surfaces at the top of the
|
|
86
100
|
// customer's order history without a stable-id tie-break flake.
|
|
@@ -356,6 +370,50 @@ function create(opts) {
|
|
|
356
370
|
return { rows: rows, next_cursor: next };
|
|
357
371
|
},
|
|
358
372
|
|
|
373
|
+
// Operator-facing recent-orders list across ALL customers (guest
|
|
374
|
+
// orders included), newest first. Unlike listForCustomer this is an
|
|
375
|
+
// admin view, so it's capped + uncursored: the console shows the most
|
|
376
|
+
// recent N, optionally filtered to one status. ship_to + lines are
|
|
377
|
+
// hydrated so the table renders without a trip per row.
|
|
378
|
+
listRecent: async function (listOpts) {
|
|
379
|
+
listOpts = listOpts || {};
|
|
380
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
381
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
|
|
382
|
+
throw new TypeError("order.listRecent: limit must be 1..." + MAX_LIST_LIMIT);
|
|
383
|
+
}
|
|
384
|
+
var status = listOpts.status == null ? null : listOpts.status;
|
|
385
|
+
if (status !== null) {
|
|
386
|
+
if (typeof status !== "string" || ORDER_STATES.indexOf(status) === -1) {
|
|
387
|
+
throw new TypeError("order.listRecent: status must be one of " + ORDER_STATES.join(", "));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
var sql, params;
|
|
391
|
+
if (status) {
|
|
392
|
+
sql = "SELECT * FROM orders WHERE status = ?1 ORDER BY created_at DESC, id DESC LIMIT ?2";
|
|
393
|
+
params = [status, limit];
|
|
394
|
+
} else {
|
|
395
|
+
sql = "SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT ?1";
|
|
396
|
+
params = [limit];
|
|
397
|
+
}
|
|
398
|
+
var rows = (await query(sql, params)).rows;
|
|
399
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
400
|
+
rows[i].ship_to = JSON.parse(rows[i].ship_to_json);
|
|
401
|
+
rows[i].lines = (await query(
|
|
402
|
+
"SELECT * FROM order_lines WHERE order_id = ?1 ORDER BY id ASC",
|
|
403
|
+
[rows[i].id],
|
|
404
|
+
)).rows;
|
|
405
|
+
}
|
|
406
|
+
return { rows: rows };
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
// The actions available from a given status, as {on, to, label} —
|
|
410
|
+
// drives the transition buttons on the operator order-detail page.
|
|
411
|
+
// A terminal status returns []. Synchronous (pure lookup).
|
|
412
|
+
transitionsFrom: function (status) {
|
|
413
|
+
return ORDER_TRANSITIONS.filter(function (t) { return t.from === status; })
|
|
414
|
+
.map(function (t) { return { on: t.on, to: t.to, label: t.label }; });
|
|
415
|
+
},
|
|
416
|
+
|
|
359
417
|
setPaymentIntent: async function (orderId, paymentIntentId) {
|
|
360
418
|
_uuid(orderId, "order id");
|
|
361
419
|
if (typeof paymentIntentId !== "string" || !paymentIntentId.length) {
|
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.12.
|
|
7
|
-
"tag": "v0.12.
|
|
6
|
+
"version": "0.12.50",
|
|
7
|
+
"tag": "v0.12.50",
|
|
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.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.50 (2026-05-25) — **`b.network.dns.dnssec.verifyChain` — validate a DNSSEC delegation chain to a pinned root anchor.** Completes local DNSSEC verification: validate a full delegation chain from the root down to a zone against a pinned trust anchor (RFC 4035 §5), instead of trusting any single resolver. For each link, the zone's DNSKEY RRset must be self-signed by one of its keys, and that key must be vouched for either by a pinned anchor (at the root) or by a DS record served + signed by the already-trusted parent — so trust flows root → TLD → zone with no gap. The IANA root KSKs (KSK-2017 tag 20326, KSK-2024 tag 38696) ship as the default anchors; override with opts.trustAnchors for a private root. verifyChain returns the leaf zone's trusted DNSKEY set, which you then hand to verifyRrset / verifyDenial for the actual answer. Composes verifyRrset + verifyDs + the key tag; verified end-to-end against a live root→org chain. **Added:** *`b.network.dns.dnssec.verifyChain(opts)`* — Walks an ordered, root-first list of `links` ({ zone, dnskeys, dnskeyRrsig, dsRdatas?, dsRrsig? }). At each link it verifies the DNSKEY RRset's self-signature (composing `verifyRrset`), then establishes trust in the signing key: at the root by matching a pinned anchor's DS digest (`verifyDs`), at every delegation by verifying the parent-served DS RRset's signature with the already-trusted parent key and confirming the signing KSK matches one of those DS records. Returns `{ ok, zone, keys, path }` with the leaf zone's trusted DNSKEY set. Refuses a root key that matches no anchor (`dnssec/chain-anchor-mismatch`), a KSK that matches no parent DS (`dnssec/chain-ds-mismatch`), and a missing parent key (`dnssec/chain-no-parent-key`). The default `DEFAULT_ROOT_ANCHORS` are the published IANA root KSK DS records; `opts.trustAnchors` overrides them for a private or test root.
|
|
12
|
+
|
|
11
13
|
- v0.12.49 (2026-05-25) — **`b.network.dns.dnssec.verifyDenial` — NSEC / NSEC3 denial-of-existence.** Prove a DNS name does not exist, or has no records of a given type, from the signed NSEC (RFC 4034 §4) or NSEC3 (RFC 5155) records a server returns. This is the other half of local DNSSEC verification: verifyRrset proves a positive answer, verifyDenial proves a negative — so a resolver client can confirm an NXDOMAIN / NODATA itself instead of trusting the upstream resolver. NSEC3 proofs run the closest-encloser / next-closer / covering-range logic over iterated-SHA-1 hashes, with the iteration count capped (default 500) to bound the work an attacker can force, and an Opt-Out NXDOMAIN refused unless explicitly accepted (opt-out only proves 'no signed records', not non-existence). The companion b.network.dns.dnssec.nsec3Hash computes the RFC 5155 §5 hash directly. NSEC verifyRrset support is also enabled: per RFC 6840 §5.1 the NSEC Next Domain Name is not downcased, so its RDATA is verbatim-canonical. **Added:** *`b.network.dns.dnssec.verifyDenial(opts)`* — Proves NXDOMAIN or NODATA from already-verified NSEC / NSEC3 records (supply one of `opts.nsec3` or `opts.nsec`). Like `verifyDs`, it checks the denial RELATION — closest-encloser matching, covering ranges, and type-bitmap absence — not the record signatures, which the caller verifies with `verifyRrset` first. NSEC3 supports name-error proofs (matching closest encloser + covered next-closer + covered wildcard), NODATA (matching record with the type and CNAME absent from the bitmap), Opt-Out DS NODATA, and wildcard NODATA. The iterated-SHA-1 count is capped by `opts.maxIterations` (default 500); an NXDOMAIN proof that depends on an Opt-Out NSEC3 is refused unless `opts.allowOptOut` is set. NSEC supports covering-name NXDOMAIN (with the source-of-synthesis wildcard) and matching-name NODATA. Verified end-to-end against a live iana.org NXDOMAIN proof. · *`b.network.dns.dnssec.nsec3Hash(name, opts)`* — Computes the RFC 5155 §5 NSEC3 hash of a name — iterated SHA-1 over the canonical (lowercased, root-terminated) wire form with the zone salt. The base32hex encoding of the result is the NSEC3 owner label. SHA-1 is the only hash IANA registers for NSEC3, so this is a wire-protocol constant rather than a cryptographic default. Useful for checking an owner label or analyzing a zone's hashing parameters. **Changed:** *`verifyRrset` now accepts NSEC and NSEC3 RRsets* — NSEC (type 47) and NSEC3 (type 50) are no longer refused as uncanonicalizable: NSEC3's next-owner is a hash, and per RFC 6840 §5.1 the NSEC Next Domain Name field is not downcased for DNSSEC canonical form, so both RDATAs are verbatim-canonical. This lets a caller verify the signatures on the records that `verifyDenial` then reasons over.
|
|
12
14
|
|
|
13
15
|
- v0.12.48 (2026-05-25) — **`b.network.dns.dnssec` — local DNSSEC signature verification (RFC 4035).** Verify a DNS answer's RRSIG signature yourself instead of trusting the upstream resolver's AD bit. b.network.dns.dnssec.verifyRrset reconstructs the RFC 4034 §3.1.8.1 signed data — the RRSIG RDATA without the signature, followed by the RRset in canonical form (owner names lowercased, RRs ordered by canonical RDATA, the RRSIG's Original TTL) — and checks the signature against the DNSKEY, enforcing the inception / expiration window. Supports RSA/SHA-256 (alg 8), ECDSA P-256/SHA-256 (13), ECDSA P-384/SHA-384 (14), and Ed25519 (15) — the modern deployed set. verifyDs checks a delegation-signer digest against a DNSKEY (SHA-256 / SHA-384) and keyTag computes the RFC 4034 Appendix B key tag. The verification core is what a chain-walker composes; it defends against a compromised or on-path resolver that lies about authentication. **Added:** *`b.network.dns.dnssec.verifyRrset(opts)`* — Verifies an RRSIG over a canonicalised RRset against a DNSKEY. `opts` carries the owner `name`, the RR `type`, the wire-format `rdatas`, the parsed `rrsig` (algorithm / labels / originalTtl / inception / expiration / keyTag / signerName / signature), and the `dnskey` (algorithm + raw public key). The signed data is rebuilt per RFC 4034 §3.1.8.1: the RRSIG prefix (type covered | algorithm | labels | original TTL | expiration | inception | key tag | canonical signer name) followed by each RR in canonical form (lowercased owner | type | class | original TTL | rdlen | rdata), sorted by `Buffer.compare` on the RDATA. The validity window is enforced against `opts.at` (defaults to now; an invalid Date is refused, not treated as now). An RRSIG whose algorithm disagrees with the DNSKEY is refused before any key is built. RR types that embed domain names in their RDATA (NS, CNAME, SOA, MX, SRV, …) need RDATA-internal name-lowercasing this version does not perform, so they are refused with `dnssec/uncanonicalizable-type` rather than mis-validated; the security-critical DNSKEY / DS and the name-free address / text types (A, AAAA, TXT, CAA, TLSA, …) are fully supported. · *`b.network.dns.dnssec.verifyDs(opts)` / `b.network.dns.dnssec.keyTag(dnskeyRdata)`* — `verifyDs` confirms a delegation-signer record matches a DNSKEY: it checks the key tag, then compares the DS digest (SHA-256 type 2 / SHA-384 type 4) against the digest computed over the canonical owner name and the DNSKEY RDATA, constant-time. `keyTag` computes the RFC 4034 Appendix B 16-bit key tag from a DNSKEY's full RDATA — the identifier an RRSIG or DS uses to select the signing key. Together with `verifyRrset` these are the per-RRset building blocks a recursive chain-walk (root → TLD → zone) composes; the chain-walk itself, NSEC / NSEC3 denial-of-existence, and the bundled IANA root trust anchor are not part of this core.
|
|
@@ -120,7 +120,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
120
120
|
- In-process CIDR fence (`b.middleware.networkAllowlist`)
|
|
121
121
|
- `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
|
|
122
122
|
- **Outbound HTTP client** — HTTP/1.1 + HTTP/2 with SSRF gate (cloud-metadata IPs hard-denied; private / loopback / link-local overridable per call); scheme + userinfo + per-host destination allowlist; redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`)
|
|
123
|
-
- **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
|
|
123
|
+
- **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling, plus `verifyChain` to validate a full root→TLD→zone delegation chain against the pinned IANA root anchors) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
|
|
124
124
|
- **Error pages** — operator-rendered, no app-frame leakage (`b.errorPage`)
|
|
125
125
|
### Defensive parsers
|
|
126
126
|
|
|
@@ -351,7 +351,7 @@ This is the minimum-viable security posture for a production deployment. The fra
|
|
|
351
351
|
- [ ] 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
|
|
352
352
|
- [ ] 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
|
|
353
353
|
- [ ] 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
|
|
354
|
-
- [ ] 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
|
|
354
|
+
- [ ] 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
|
|
355
355
|
- [ ] 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
|
|
356
356
|
- [ ] At boot: call `await b.configDrift.create({ dataDir, audit }).checkpoint({ allowedOrigins, csp, vaultMode, ... })` so the next boot detects + audits any silent runtime config change
|
|
357
357
|
- [ ] 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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.12.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.12.50",
|
|
4
|
+
"createdAt": "2026-05-25T13:31:06.521Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -41674,6 +41674,10 @@
|
|
|
41674
41674
|
}
|
|
41675
41675
|
}
|
|
41676
41676
|
},
|
|
41677
|
+
"DEFAULT_ROOT_ANCHORS": {
|
|
41678
|
+
"type": "instance",
|
|
41679
|
+
"ctorName": "Array"
|
|
41680
|
+
},
|
|
41677
41681
|
"DnssecError": {
|
|
41678
41682
|
"type": "function",
|
|
41679
41683
|
"arity": 4
|
|
@@ -41686,6 +41690,10 @@
|
|
|
41686
41690
|
"type": "function",
|
|
41687
41691
|
"arity": 2
|
|
41688
41692
|
},
|
|
41693
|
+
"verifyChain": {
|
|
41694
|
+
"type": "function",
|
|
41695
|
+
"arity": 1
|
|
41696
|
+
},
|
|
41689
41697
|
"verifyDenial": {
|
|
41690
41698
|
"type": "function",
|
|
41691
41699
|
"arity": 1
|
|
@@ -721,12 +721,163 @@ function _commonSuffixLen(a, b) {
|
|
|
721
721
|
return n;
|
|
722
722
|
}
|
|
723
723
|
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// Chain validation (RFC 4035 §5) — walk a delegation chain root → … → zone,
|
|
726
|
+
// anchoring at a pinned trust anchor.
|
|
727
|
+
// ---------------------------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
// IANA root zone trust anchors (DS / SHA-256). KSK-2017 (tag 20326) and
|
|
730
|
+
// KSK-2024 (tag 38696), published at data.iana.org/root-anchors. An
|
|
731
|
+
// operator pins their own via opts.trustAnchors.
|
|
732
|
+
var DEFAULT_ROOT_ANCHORS = [
|
|
733
|
+
{ keyTag: 20326, algorithm: 8, digestType: 2, digest: Buffer.from("E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D", "hex") }, // allow:raw-byte-literal — IANA root KSK-2017 DS
|
|
734
|
+
{ keyTag: 38696, algorithm: 8, digestType: 2, digest: Buffer.from("683D2D0ACB8C9B712A1948B27F741219298D0A450D612C483AF444A4C0FB2B16", "hex") }, // allow:raw-byte-literal — IANA root KSK-2024 DS
|
|
735
|
+
];
|
|
736
|
+
|
|
737
|
+
function _dnskeyParts(rdata, what) {
|
|
738
|
+
var rd = _bytes(rdata, what || "dnskey rdata");
|
|
739
|
+
if (rd.length < 4) throw new DnssecError("dnssec/bad-key", "dnssec: DNSKEY RDATA too short"); // allow:raw-byte-literal — DNSKEY fixed header octets
|
|
740
|
+
return { flags: rd.readUInt16BE(0), algorithm: rd[3], publicKey: rd.slice(4) };
|
|
741
|
+
}
|
|
742
|
+
function _parseDsRdata(rd) {
|
|
743
|
+
if (rd.length < 5) throw new DnssecError("dnssec/bad-ds", "dnssec: DS RDATA too short"); // allow:raw-byte-literal — DS fixed header octets
|
|
744
|
+
return { keyTag: rd.readUInt16BE(0), algorithm: rd[2], digestType: rd[3], digest: rd.slice(4) };
|
|
745
|
+
}
|
|
746
|
+
// ALL DNSKEYs whose key tag matches — 16-bit key tags collide (RFC 4034
|
|
747
|
+
// App B), so a verifier must try every candidate, not just the first.
|
|
748
|
+
function _keysByTag(dnskeys, tag) {
|
|
749
|
+
var out = [];
|
|
750
|
+
for (var i = 0; i < dnskeys.length; i++) if (keyTag(dnskeys[i]) === tag) out.push(dnskeys[i]);
|
|
751
|
+
return out;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Verify an RRset against EVERY DNSKEY whose tag matches the RRSIG,
|
|
755
|
+
// returning the key that validated. A wrong colliding key yields
|
|
756
|
+
// `dnssec/bad-signature` — that is not terminal, the next candidate is
|
|
757
|
+
// tried; any other error (expired, alg) is terminal. RFC 4035 §5.3.1.
|
|
758
|
+
function _verifyRrsetWithAnyKey(rrsetBase, rrsig, candidates, noKeyCode, noKeyMsg) {
|
|
759
|
+
if (candidates.length === 0) throw new DnssecError(noKeyCode, noKeyMsg);
|
|
760
|
+
var lastErr = null;
|
|
761
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
762
|
+
var kp = _dnskeyParts(candidates[i]);
|
|
763
|
+
if (kp.algorithm !== rrsig.algorithm) { lastErr = new DnssecError("dnssec/alg-mismatch", "dnssec.verifyChain: candidate key algorithm does not match the RRSIG"); continue; }
|
|
764
|
+
try {
|
|
765
|
+
verifyRrset(Object.assign({}, rrsetBase, { rrsig: rrsig, dnskey: { algorithm: kp.algorithm, publicKey: kp.publicKey } }));
|
|
766
|
+
return candidates[i];
|
|
767
|
+
} catch (e) {
|
|
768
|
+
if (e && e.code === "dnssec/bad-signature") { lastErr = e; continue; } // colliding non-signing key — try the next
|
|
769
|
+
throw e;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
throw lastErr;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* @primitive b.network.dns.dnssec.verifyChain
|
|
777
|
+
* @signature b.network.dns.dnssec.verifyChain(opts)
|
|
778
|
+
* @since 0.12.50
|
|
779
|
+
* @status stable
|
|
780
|
+
* @compliance soc2
|
|
781
|
+
* @related b.network.dns.dnssec.verifyRrset, b.network.dns.dnssec.verifyDs
|
|
782
|
+
*
|
|
783
|
+
* Validate a DNSSEC delegation chain from the root down to a zone, against
|
|
784
|
+
* a pinned trust anchor (RFC 4035 §5). For each link, the zone's DNSKEY
|
|
785
|
+
* RRset must be self-signed by one of its keys; that signing key must be
|
|
786
|
+
* vouched for either by a pinned anchor (root) or by a DS record served by
|
|
787
|
+
* the already-trusted parent. The DS RRset itself is verified against the
|
|
788
|
+
* parent's keys, so trust flows root → TLD → zone with no gap. The default
|
|
789
|
+
* anchors are the IANA root KSKs; override with <code>opts.trustAnchors</code>.
|
|
790
|
+
*
|
|
791
|
+
* This composes <code>verifyRrset</code> + <code>verifyDs</code> + the key
|
|
792
|
+
* tag; it returns the leaf zone's trusted DNSKEY set, which the caller then
|
|
793
|
+
* passes to <code>verifyRrset</code> / <code>verifyDenial</code> for the
|
|
794
|
+
* actual answer.
|
|
795
|
+
*
|
|
796
|
+
* @opts
|
|
797
|
+
* {
|
|
798
|
+
* links: [ { // ordered root-first
|
|
799
|
+
* zone: string,
|
|
800
|
+
* dnskeys: Buffer[], // the zone's DNSKEY RRset RDATAs
|
|
801
|
+
* dnskeyRrsig: { algorithm, labels, originalTtl, expiration, inception, keyTag, signerName, signature },
|
|
802
|
+
* dsRdatas?: Buffer[], // DS RRset for this zone (served by parent; omit for root)
|
|
803
|
+
* dsRrsig?: { ... }, // RRSIG over the DS RRset (signed by parent; omit for root)
|
|
804
|
+
* } ],
|
|
805
|
+
* trustAnchors?: [ { keyTag, algorithm, digestType, digest: Buffer } ], // default IANA root
|
|
806
|
+
* at?: Date, // validity instant (default now)
|
|
807
|
+
* }
|
|
808
|
+
*
|
|
809
|
+
* @example
|
|
810
|
+
* var trusted = b.network.dns.dnssec.verifyChain({ links: [rootLink, orgLink] });
|
|
811
|
+
* // → { ok: true, zone: "org.", keys: [ ...trusted org DNSKEY rdatas ] }
|
|
812
|
+
*/
|
|
813
|
+
function verifyChain(opts) {
|
|
814
|
+
validateOpts.requireObject(opts, "dnssec.verifyChain", DnssecError);
|
|
815
|
+
validateOpts(opts, ["links", "trustAnchors", "at"], "dnssec.verifyChain");
|
|
816
|
+
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");
|
|
817
|
+
var anchors = opts.trustAnchors !== undefined ? opts.trustAnchors : DEFAULT_ROOT_ANCHORS;
|
|
818
|
+
if (!Array.isArray(anchors) || anchors.length === 0) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyChain: opts.trustAnchors must be a non-empty array");
|
|
819
|
+
|
|
820
|
+
var trustedKeys = null, path = [];
|
|
821
|
+
for (var i = 0; i < opts.links.length; i++) {
|
|
822
|
+
var link = opts.links[i];
|
|
823
|
+
if (!link || typeof link.zone !== "string" || link.zone === "") throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].zone is required");
|
|
824
|
+
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");
|
|
825
|
+
if (!link.dnskeyRrsig || typeof link.dnskeyRrsig !== "object") throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "].dnskeyRrsig is required");
|
|
826
|
+
|
|
827
|
+
// 1. The DNSKEY RRset is self-signed by one of its own keys (trying
|
|
828
|
+
// every key whose tag matches, since tags collide).
|
|
829
|
+
var signer = _verifyRrsetWithAnyKey(
|
|
830
|
+
{ name: link.zone, type: "DNSKEY", rdatas: link.dnskeys, at: opts.at },
|
|
831
|
+
link.dnskeyRrsig,
|
|
832
|
+
_keysByTag(link.dnskeys, link.dnskeyRrsig.keyTag),
|
|
833
|
+
"dnssec/chain-no-signing-key", "dnssec.verifyChain: no DNSKEY in '" + link.zone + "' verifies the DNSKEY RRSIG"
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
// 2. Establish trust in the signing key.
|
|
837
|
+
var signerTag = keyTag(signer);
|
|
838
|
+
if (i === 0) {
|
|
839
|
+
// Root: the signing key must match a pinned anchor's DS digest.
|
|
840
|
+
var matched = false;
|
|
841
|
+
for (var a = 0; a < anchors.length; a++) {
|
|
842
|
+
if (anchors[a].keyTag !== signerTag) continue;
|
|
843
|
+
try { verifyDs({ ownerName: link.zone, dnskeyRdata: signer, ds: anchors[a] }); matched = true; break; } catch (_e) { /* try the next anchor */ }
|
|
844
|
+
}
|
|
845
|
+
if (!matched) throw new DnssecError("dnssec/chain-anchor-mismatch", "dnssec.verifyChain: root DNSKEY does not match any pinned trust anchor");
|
|
846
|
+
} else {
|
|
847
|
+
// Delegation: the parent (already trusted) signed a DS RRset for this
|
|
848
|
+
// zone, and the signing KSK matches one of those DS records.
|
|
849
|
+
if (!Array.isArray(link.dsRdatas) || link.dsRdatas.length === 0 || !link.dsRrsig || typeof link.dsRrsig !== "object") {
|
|
850
|
+
throw new DnssecError("dnssec/bad-link", "dnssec.verifyChain: links[" + i + "] needs dsRdatas + dsRrsig (DS served by the parent)");
|
|
851
|
+
}
|
|
852
|
+
_verifyRrsetWithAnyKey(
|
|
853
|
+
{ name: link.zone, type: "DS", rdatas: link.dsRdatas, at: opts.at },
|
|
854
|
+
link.dsRrsig,
|
|
855
|
+
_keysByTag(trustedKeys, link.dsRrsig.keyTag),
|
|
856
|
+
"dnssec/chain-no-parent-key", "dnssec.verifyChain: no trusted parent key verifies the DS RRSIG for '" + link.zone + "'"
|
|
857
|
+
);
|
|
858
|
+
var dsMatched = false;
|
|
859
|
+
for (var d = 0; d < link.dsRdatas.length; d++) {
|
|
860
|
+
var dsObj = _parseDsRdata(_bytes(link.dsRdatas[d], "dsRdatas[" + d + "]"));
|
|
861
|
+
if (dsObj.keyTag !== signerTag) continue;
|
|
862
|
+
try { verifyDs({ ownerName: link.zone, dnskeyRdata: signer, ds: dsObj }); dsMatched = true; break; } catch (_e) { /* try the next DS */ }
|
|
863
|
+
}
|
|
864
|
+
if (!dsMatched) throw new DnssecError("dnssec/chain-ds-mismatch", "dnssec.verifyChain: the signing KSK of '" + link.zone + "' matches no parent-signed DS");
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
trustedKeys = link.dnskeys;
|
|
868
|
+
path.push(link.zone);
|
|
869
|
+
}
|
|
870
|
+
return { ok: true, zone: opts.links[opts.links.length - 1].zone, keys: trustedKeys, path: path };
|
|
871
|
+
}
|
|
872
|
+
|
|
724
873
|
module.exports = {
|
|
725
|
-
verifyRrset:
|
|
726
|
-
verifyDs:
|
|
727
|
-
verifyDenial:
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
874
|
+
verifyRrset: verifyRrset,
|
|
875
|
+
verifyDs: verifyDs,
|
|
876
|
+
verifyDenial: verifyDenial,
|
|
877
|
+
verifyChain: verifyChain,
|
|
878
|
+
nsec3Hash: nsec3Hash,
|
|
879
|
+
keyTag: keyTag,
|
|
880
|
+
ALGORITHMS: ALGS,
|
|
881
|
+
DEFAULT_ROOT_ANCHORS: DEFAULT_ROOT_ANCHORS,
|
|
882
|
+
DnssecError: DnssecError,
|
|
732
883
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.50",
|
|
4
|
+
"date": "2026-05-25",
|
|
5
|
+
"headline": "`b.network.dns.dnssec.verifyChain` — validate a DNSSEC delegation chain to a pinned root anchor",
|
|
6
|
+
"summary": "Completes local DNSSEC verification: validate a full delegation chain from the root down to a zone against a pinned trust anchor (RFC 4035 §5), instead of trusting any single resolver. For each link, the zone's DNSKEY RRset must be self-signed by one of its keys, and that key must be vouched for either by a pinned anchor (at the root) or by a DS record served + signed by the already-trusted parent — so trust flows root → TLD → zone with no gap. The IANA root KSKs (KSK-2017 tag 20326, KSK-2024 tag 38696) ship as the default anchors; override with opts.trustAnchors for a private root. verifyChain returns the leaf zone's trusted DNSKEY set, which you then hand to verifyRrset / verifyDenial for the actual answer. Composes verifyRrset + verifyDs + the key tag; verified end-to-end against a live root→org chain.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.network.dns.dnssec.verifyChain(opts)`",
|
|
13
|
+
"body": "Walks an ordered, root-first list of `links` ({ zone, dnskeys, dnskeyRrsig, dsRdatas?, dsRrsig? }). At each link it verifies the DNSKEY RRset's self-signature (composing `verifyRrset`), then establishes trust in the signing key: at the root by matching a pinned anchor's DS digest (`verifyDs`), at every delegation by verifying the parent-served DS RRset's signature with the already-trusted parent key and confirming the signing KSK matches one of those DS records. Returns `{ ok, zone, keys, path }` with the leaf zone's trusted DNSKEY set. Refuses a root key that matches no anchor (`dnssec/chain-anchor-mismatch`), a KSK that matches no parent DS (`dnssec/chain-ds-mismatch`), and a missing parent key (`dnssec/chain-no-parent-key`). The default `DEFAULT_ROOT_ANCHORS` are the published IANA root KSK DS records; `opts.trustAnchors` overrides them for a private or test root."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -6277,6 +6277,19 @@ var KNOWN_ANTIPATTERNS = [
|
|
|
6277
6277
|
allowlist: [],
|
|
6278
6278
|
reason: "DNSSEC NXDOMAIN over-acceptance — for a Name Error proof the source-of-synthesis wildcard must be COVERED (proven absent). A matching wildcard owner means the wildcard exists and the query should have been answered by expansion, so a response claiming NXDOMAIN is forged. The `!findCover(x) && !findMatch(x)` gate accepts a matching wildcard as proof and must never appear; the correct gate is `!findCover(x)`. Detection is precise: only the cover-OR-match denial gate matches. Wildcard-NODATA (which legitimately needs a MATCHING wildcard with the type absent) uses `findMatch(...)` standalone with a type-bitmap check, not this gate, so it does not match.",
|
|
6279
6279
|
},
|
|
6280
|
+
{
|
|
6281
|
+
// DNSSEC key selection: 16-bit key tags collide (RFC 4034 App B), so
|
|
6282
|
+
// selecting a SINGLE DNSKEY by tag and verifying only against it
|
|
6283
|
+
// yields a false `bad-signature` when a colliding non-signing key
|
|
6284
|
+
// appears first in the RRset. A verifier must try EVERY key whose
|
|
6285
|
+
// tag (and algorithm) match (RFC 4035 §5.3.1) — `_keysByTag` +
|
|
6286
|
+
// `_verifyRrsetWithAnyKey`, never a `_findKeyByTag`-style single pick.
|
|
6287
|
+
id: "dnssec-single-key-by-tag",
|
|
6288
|
+
primitive: "_keysByTag(...) + try-every-candidate — never a single-result _findKeyByTag for signature verification",
|
|
6289
|
+
regex: /_findKeyByTag\s*\(/,
|
|
6290
|
+
allowlist: [],
|
|
6291
|
+
reason: "DNSSEC key-tag collision false-negative — a 16-bit DNSKEY tag is not unique within an RRset (RFC 4034 Appendix B explicitly permits collisions). Picking the first key with a matching tag and verifying only against it rejects an otherwise-valid chain when a colliding non-signing key sorts earlier. RFC 4035 §5.3.1 requires trying every key whose tag and algorithm match until one validates; the framework does this via `_keysByTag` + `_verifyRrsetWithAnyKey`. The single-result `_findKeyByTag` helper must not be (re)introduced for signature key selection.",
|
|
6292
|
+
},
|
|
6280
6293
|
{
|
|
6281
6294
|
// CVE-2026-23552 — cross-realm JWT acceptance via non-CT iss
|
|
6282
6295
|
// compare. `payload.iss !== expectedIssuer` (or claims.iss / token.iss)
|
|
@@ -59,6 +59,10 @@ function testSurface() {
|
|
|
59
59
|
check("b.network.dns.dnssec.verifyRrset is a function", typeof b.network.dns.dnssec.verifyRrset === "function");
|
|
60
60
|
check("b.network.dns.dnssec.verifyDs is a function", typeof b.network.dns.dnssec.verifyDs === "function");
|
|
61
61
|
check("b.network.dns.dnssec.keyTag is a function", typeof b.network.dns.dnssec.keyTag === "function");
|
|
62
|
+
check("b.network.dns.dnssec.verifyDenial is a function", typeof b.network.dns.dnssec.verifyDenial === "function");
|
|
63
|
+
check("b.network.dns.dnssec.nsec3Hash is a function", typeof b.network.dns.dnssec.nsec3Hash === "function");
|
|
64
|
+
check("b.network.dns.dnssec.verifyChain is a function", typeof b.network.dns.dnssec.verifyChain === "function");
|
|
65
|
+
check("b.network.dns.dnssec.DEFAULT_ROOT_ANCHORS includes the IANA KSK tags", Array.isArray(b.network.dns.dnssec.DEFAULT_ROOT_ANCHORS) && b.network.dns.dnssec.DEFAULT_ROOT_ANCHORS.some(function (a) { return a.keyTag === 20326; }));
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
function testRealVectors() {
|
|
@@ -270,11 +274,128 @@ function testDenialArgs() {
|
|
|
270
274
|
check("verifyDenial: nodata without qtype refused", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "iana.org", proof: "nodata", zone: "iana.org", nsec3: ianaRecords() }); }) === "dnssec/bad-arg");
|
|
271
275
|
}
|
|
272
276
|
|
|
277
|
+
// --- Chain validation (verifyChain) ---
|
|
278
|
+
|
|
279
|
+
// Real root→org DNSSEC chain captured via Cloudflare DoH: the root DNSKEY
|
|
280
|
+
// RRset (signed by the IANA KSK), the org DS RRset (served + signed by the
|
|
281
|
+
// root), and the org DNSKEY RRset (signed by org's KSK).
|
|
282
|
+
var ROOT_DNSKEY_HEX = "123481a00001000400000001000030000100003000010000000e01080100030803010001be5d0d87dfa60009f155062f042d5973e5416b2320526d08cd34fd768a53ef259fea1f6a1dead8ac44223bf3420fa7a9dc518fef1e9ad3e77b59ad61c6c558fe10f44f839e23892cad3d474e45bb3bc66eb1bb0c37510d45ff71e745755ecef29144018a49a98351f4109320057def70ced9b89ab8a480df56fb23694aff0a31a11d6d7f972a27848c6c952f8ae1e2700128522d804ecc25a193567794f9b619841599f1171ec3e5480a098ee87e54bbf8653b74d27012d9859d66151131cdd241d7573e9a82ea2e680669ef4e985cd22847f893810866b11ed75fec0bd19f103362f1408c94eaf459d3a232b8930644c8b0912b861256ee9b206dd762596eb500003000010000000e01080101030803010001acffb409bcc939f831f7a1e5ec88f7a59255ec53040be432027390a4ce896d6f9086f3c5e177fbfe118163aaec7af1462c47945944c4e2c026be5e98bbcded25978272e1e3e079c5094d573f0e83c92f02b32d3513b1550b826929c80dd0f92cac966d17769fd5867b647c3f38029abdc48152eb8f207159ecc5d232c7c1537c79f4b7ac28ff11682f21681bf6d6aba555032bf6f9f036beb2aaa5b3778d6eebfba6bf9ea191be4ab0caea759e2f773a1f9029c73ecb8d5735b9321db085f1b8e2d8038fe2941992548cee0d67dd4547e11dd63af9c9fc1c5466fb684cf009d7197c2cf79e792ab501e6a8a1ca519af2cb9b5f6367e94c0d47502451357be1b500003000010000000e01080101030803010001af7a8deba49d995a792aefc80263e991efdbc86138a931deb2c65d5682eab5d3b03738e3dfdc89d96da64c86c0224d9ce02514d285da3068b19054e5e787b2969058e98e12566c8c808c40c0b769e1db1a24a1bd9b31e303184a31fc7bb56b85bbba8abc02cd5040a444a36d47695969849e16ad856bb58e8fac8855224400319bdab224d83fc0e66aab32ff74bfeaf0f91c454e6850a1295207bbd4cdde8f6ffb08faa9755c2e3284efa01f99393e18786cb132f1e66ebc6517318e1ce8a3b7337ebb54d035ab57d9706ecd9350d4afacd825e43c8668eece89819caf6817af62dc4fbd82f0e33f6647b2b6bda175f14607f59f4635451e6b27df282ef73d8700002e00010000000e0113003008000002a3006a29fa806a0e4b004f66003eb63aef891c6aa08533d04c2e51d08c1a6834df2a30af63d3fec27ec4ac17dfc21384c03bc1c1df400af2f1c2ab80788e20f8383a3dfd8eb01f48b8d4430d191e58baddb7fcdeec2cf381d042d094535b7595071c082aa88794db2c0d56fda210a29df0b7f456699235921050261075ecb2ab6c63e716768c0b5db2def27eb62958808a5a2dddde98a2375e2bd9ed6e89f34fea1f222fb7fa70032c1e9357dafc378ab72207826c9d7674584679a743825e68146d759c0e886a2de996daf752aa5ae00f8297842aef9eac3bd27a698ec475719f22ac9ee8345e3b07a2a67aedee0a406309744bb7907ed1de6e266bad02f9e2caa297277e7715d77ce7d2772f00002904d0000080000000";
|
|
283
|
+
var ORG_DS_HEX = "123481a00001000200000001036f726700002b0001c00c002b0001000112ef0024695e08024fede294c53f438a158c41d39489cd78a86beb0d8a0aeaff14745c0d16e1de32c00c002e0001000112ef0113002b0801000151806a24fad06a13c940d4790018af430b9631a0da77f1b652ce2a45e82d045e6efb87d9b9e90e25d463a73ae86001e74a171f1c43a23115c4ab9192939cddc1a90c4405998c8d508f0ec5b345a11a64b7d9ea660b497bb629c8ed908c69ed982301cdc5b108272b06aa9626cd5174028f9ac03609d1c560fb96309b6c5166653b21b7a197a5c30678b5be18dd8e405df36414e04ca33658b7a1da402bd2f0e2a31c84c01385ca70cf6492ec2f2e6c04085308a1ac112638d6f286f66f0b726bacad46ea75f4221b173008db2d21f412cecbc085893f273110d4afc485e804ba80a96d7729ba84bddb684d502fd0041f8285306874860cb9cda0da394426b5df50debf57ef6d0f9261d8d7576300002904d0000080000000";
|
|
284
|
+
var ORG_DNSKEY_HEX = "123481a00001000400000001036f72670000300001c00c003000010000013200880100030803010001b1ac5fd2e78ca6b3eeb87e59ae4826bbdbaaddc35318f337942d3f207026d95c135d8e35863309bcd365a5c46223c90a6305467dfd6c3a874eb952ae11c7a9e5f177ae31ce9c0f6eeff1b331fcad3e32683cc254f38de0d92ef8188669ea9b7f30d78e82e8e6961dd390673941d67f397df95bd00859ec2306924eee737ec62bc00c003000010000013200880100030803010001d737ce87b2f7a67133bcf13a4c21b78ea38fa07bf278dbd919161afcaeafca081b1e1e01bcfe237f0dd929f7c695dea6c5e19853935224f6034b52c9eeb255a0b797304b771bf466d28f38b0e039276e90c673d7901a3937e9a3294e4d78ce1f9c26ff722e1b68b735bc680b405221ef6cd6a4da982778ee42d0db2bfd8031f1c00c003000010000013201080101030803010001ec5927fd707f2342c4d3eb4d98b3686ed49626c684ff80ca8811e9baa3a6d2b0784804e200fc1b1450264b7e167e690a836ace69b56671282aabe6f77b8e62d6ca918403187f96ebd27ae8c48aa602324be993ff889a5e7fa5a5be68f7d97fb1fee51cc6bdef93bf7ea37a68f5259788546cdd93d4b1efe87cd0b900ad351240f231c6c17e6ad0d32687667ec9a1ba07d70849eae37bd9792fe203db2749eb35eab98b5ea0852f5f6c73aa75c27473d2bc3fa82fa47fc1e5beb73b5d152d61a24ff5cf7b67e4dce671a38b965f675e7882a331292c51063ecc32beda615a0098848f1b053aa152c2307c8c0b481413120da8097eec4b909b5e0e383aac21216fc00c002e00010000013201170030080100000e106a258d3e6a09cfae695e036f726700831ed0c4fcc15125bfdd584858c72a88c4f837d8c15e7e276976417a3de45fe6ed14418035accd04081facdc0a0091bde55164a4e820514b49857155c2163b7ab07bf6d7d41003cd7971154bbf345f10ef04e9aa88e861c727c63d3f0ac2c484916360a1efa3ebef5cead2092b7c61ed8ebfccb40a79bce9d0082892d097cbd5541256ccaa09c96117e45ccdbd4d45b46145f902a81d660441f6e28ce6ec2cc83e773508947a7892cd2b1c91f8de73ed13f4199e7f3c097cb979c97bd97a34cfd0011647037c2c23da4a865960aa0f55a1d7a1c3817f0fb2f1bb7d6380eb5da578898fb0aebd5a9383ac5a1b76d6013a1a20832c53ea46ae5c6d35a06e6cc50300002904d0000080000000";
|
|
285
|
+
|
|
286
|
+
function _readN(buf, off) {
|
|
287
|
+
var ls = [], jumped = false, end = off, g = 0;
|
|
288
|
+
for (;;) { if (++g > 128) throw new Error("name guard"); var len = buf[off]; if (len === 0) { off++; if (!jumped) end = off; break; } if ((len & 0xc0) === 0xc0) { if (!jumped) end = off + 2; off = ((len & 0x3f) << 8) | buf[off + 1]; jumped = true; continue; } off++; ls.push(buf.slice(off, off + len).toString("ascii")); off += len; }
|
|
289
|
+
return { name: ls.join(".") + (ls.length ? "." : "."), end: end };
|
|
290
|
+
}
|
|
291
|
+
function parseAnswer(hex) {
|
|
292
|
+
var buf = Buffer.from(hex, "hex");
|
|
293
|
+
var qd = buf.readUInt16BE(4), an = buf.readUInt16BE(6), off = 12;
|
|
294
|
+
for (var i = 0; i < qd; i++) off = _readN(buf, off).end + 4;
|
|
295
|
+
var out = { dnskeys: [], ds: [], rrsig: {} };
|
|
296
|
+
for (var j = 0; j < an; j++) {
|
|
297
|
+
off = _readN(buf, off).end;
|
|
298
|
+
var type = buf.readUInt16BE(off), rdlen = buf.readUInt16BE(off + 8); off += 10;
|
|
299
|
+
var rd = buf.slice(off, off + rdlen); off += rdlen;
|
|
300
|
+
if (type === 48) out.dnskeys.push(rd);
|
|
301
|
+
else if (type === 43) out.ds.push(rd);
|
|
302
|
+
else if (type === 46) { var sn = _readN(rd, 18); out.rrsig[rd.readUInt16BE(0)] = { algorithm: rd[2], labels: rd[3], originalTtl: rd.readUInt32BE(4), expiration: rd.readUInt32BE(8), inception: rd.readUInt32BE(12), keyTag: rd.readUInt16BE(16), signerName: sn.name, signature: rd.slice(sn.end) }; }
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function testVerifyChain() {
|
|
308
|
+
var root = parseAnswer(ROOT_DNSKEY_HEX), orgDs = parseAnswer(ORG_DS_HEX), orgDk = parseAnswer(ORG_DNSKEY_HEX);
|
|
309
|
+
var maxInc = Math.max(root.rrsig[48].inception, orgDs.rrsig[43].inception, orgDk.rrsig[48].inception);
|
|
310
|
+
var at = new Date((maxInc + 60) * 1000);
|
|
311
|
+
function rootLink() { return { zone: ".", dnskeys: root.dnskeys, dnskeyRrsig: root.rrsig[48] }; }
|
|
312
|
+
function orgLink() { return { zone: "org.", dnskeys: orgDk.dnskeys, dnskeyRrsig: orgDk.rrsig[48], dsRdatas: orgDs.ds, dsRrsig: orgDs.rrsig[43] }; }
|
|
313
|
+
|
|
314
|
+
var out = b.network.dns.dnssec.verifyChain({ links: [rootLink(), orgLink()], at: at });
|
|
315
|
+
check("verifyChain: real root→org chain validates to the pinned IANA anchor", out.ok && out.zone === "org." && out.path.join(",") === ".,org." && out.keys.length === 3);
|
|
316
|
+
|
|
317
|
+
// Root link alone validates against the default anchor.
|
|
318
|
+
var rootOnly = b.network.dns.dnssec.verifyChain({ links: [rootLink()], at: at });
|
|
319
|
+
check("verifyChain: root DNSKEY alone validates against the default anchor", rootOnly.ok && rootOnly.zone === ".");
|
|
320
|
+
|
|
321
|
+
function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
|
|
322
|
+
// A bogus trust anchor breaks the chain at the root.
|
|
323
|
+
var badAnchor = [{ keyTag: 20326, algorithm: 8, digestType: 2, digest: Buffer.alloc(32, 0xff) }];
|
|
324
|
+
check("verifyChain: wrong trust anchor refused", code(function () { b.network.dns.dnssec.verifyChain({ links: [rootLink()], at: at, trustAnchors: badAnchor }); }) === "dnssec/chain-anchor-mismatch");
|
|
325
|
+
// A tampered org DS digest breaks the DS RRset signature.
|
|
326
|
+
check("verifyChain: tampered DS RRset refused", code(function () {
|
|
327
|
+
var bad = orgLink(); bad.dsRdatas = [Buffer.from(orgDs.ds[0])]; bad.dsRdatas[0][bad.dsRdatas[0].length - 1] ^= 0xff;
|
|
328
|
+
b.network.dns.dnssec.verifyChain({ links: [rootLink(), bad], at: at });
|
|
329
|
+
}) === "dnssec/bad-signature");
|
|
330
|
+
// Expired (at past every window).
|
|
331
|
+
check("verifyChain: expired link refused", code(function () { b.network.dns.dnssec.verifyChain({ links: [rootLink()], at: new Date((root.rrsig[48].expiration + 86400) * 1000) }); }) === "dnssec/expired");
|
|
332
|
+
// Empty links refused.
|
|
333
|
+
check("verifyChain: empty links refused", code(function () { b.network.dns.dnssec.verifyChain({ links: [] }); }) === "dnssec/bad-arg");
|
|
334
|
+
|
|
335
|
+
// Key-tag collision: two keys in the SIGNED DNSKEY RRset share a tag
|
|
336
|
+
// (16-bit tags collide, RFC 4034 App B). The non-signing one sorts
|
|
337
|
+
// first; verifyChain must try every matching key (RFC 4035 §5.3.1) and
|
|
338
|
+
// not return a false bad-signature.
|
|
339
|
+
testKeyTagCollision();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
var nodeCrypto = require("node:crypto");
|
|
343
|
+
function _ecDnskey(pubKey) {
|
|
344
|
+
var jwk = pubKey.export({ format: "jwk" });
|
|
345
|
+
var x = Buffer.from(jwk.x, "base64url"), y = Buffer.from(jwk.y, "base64url");
|
|
346
|
+
return Buffer.concat([Buffer.from([0x01, 0x01, 3, 13]), x, y]); // flags 257 (KSK/SEP), proto 3, alg 13 (ECDSAP256SHA256)
|
|
347
|
+
}
|
|
348
|
+
function _canonName(name) {
|
|
349
|
+
var n = name.replace(/\.$/, ""), parts = [];
|
|
350
|
+
if (n !== "") n.split(".").forEach(function (l) { var bb = Buffer.from(l.toLowerCase(), "ascii"); parts.push(Buffer.from([bb.length]), bb); });
|
|
351
|
+
parts.push(Buffer.from([0]));
|
|
352
|
+
return Buffer.concat(parts);
|
|
353
|
+
}
|
|
354
|
+
function _u16b(n) { return Buffer.from([(n >> 8) & 0xff, n & 0xff]); }
|
|
355
|
+
function _u32b(n) { var b2 = Buffer.alloc(4); b2.writeUInt32BE(n >>> 0, 0); return b2; }
|
|
356
|
+
// Build an RRSIG over a DNSKEY RRset signed by an EC P-256 key (mirrors
|
|
357
|
+
// the RFC 4034 §3.1.8.1 signed-data form verifyRrset reconstructs).
|
|
358
|
+
function _signDnskeyRrset(zone, rdatas, priv, signerRdata, inc, exp) {
|
|
359
|
+
var owner = _canonName(zone), ttl = _u32b(3600), labels = zone.replace(/\.$/, "") === "" ? 0 : zone.replace(/\.$/, "").split(".").length;
|
|
360
|
+
var keyTag = b.network.dns.dnssec.keyTag(signerRdata);
|
|
361
|
+
var sorted = rdatas.slice().sort(Buffer.compare);
|
|
362
|
+
var rrs = [];
|
|
363
|
+
sorted.forEach(function (rd) { rrs.push(owner, _u16b(48), _u16b(1), ttl, _u16b(rd.length), rd); });
|
|
364
|
+
var prefix = Buffer.concat([_u16b(48), Buffer.from([13, labels]), ttl, _u32b(exp), _u32b(inc), _u16b(keyTag), _canonName(zone)]);
|
|
365
|
+
var signed = Buffer.concat([prefix].concat(rrs));
|
|
366
|
+
var signature = nodeCrypto.sign("sha256", signed, { key: priv, dsaEncoding: "ieee-p1363" });
|
|
367
|
+
return { algorithm: 13, labels: labels, originalTtl: 3600, expiration: exp, inception: inc, keyTag: keyTag, signerName: zone, signature: signature };
|
|
368
|
+
}
|
|
369
|
+
function testKeyTagCollision() {
|
|
370
|
+
// Generate EC P-256 keypairs until two DNSKEYs collide on key tag.
|
|
371
|
+
var byTag = {}, a = null, b2 = null;
|
|
372
|
+
for (var i = 0; i < 4000 && !b2; i++) {
|
|
373
|
+
var kp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
374
|
+
var rd = _ecDnskey(kp.publicKey);
|
|
375
|
+
var t = b.network.dns.dnssec.keyTag(rd);
|
|
376
|
+
if (byTag[t]) { a = byTag[t]; b2 = { rd: rd, priv: kp.privateKey }; } else byTag[t] = { rd: rd, priv: kp.privateKey };
|
|
377
|
+
}
|
|
378
|
+
check("test generated a key-tag collision", b2 !== null);
|
|
379
|
+
if (!b2) return;
|
|
380
|
+
// `a` is the signer; `b2` (same tag, different key) sorts into the set.
|
|
381
|
+
var rdatas = [a.rd, b2.rd];
|
|
382
|
+
var now = Math.floor(Date.now() / 1000);
|
|
383
|
+
var rrsig = _signDnskeyRrset("test.", rdatas, a.priv, a.rd, now - 60, now + 86400);
|
|
384
|
+
// Trust anchor = DS of the signer (SHA-256 over owner + DNSKEY rdata).
|
|
385
|
+
var digest = nodeCrypto.createHash("sha256").update(Buffer.concat([_canonName("test."), a.rd])).digest();
|
|
386
|
+
var anchor = { keyTag: b.network.dns.dnssec.keyTag(a.rd), algorithm: 13, digestType: 2, digest: digest };
|
|
387
|
+
// Order the set so the non-signing colliding key is tried first.
|
|
388
|
+
var ordered = (b.network.dns.dnssec.keyTag(rdatas[0]) === rrsig.keyTag && rdatas[0] !== a.rd) ? rdatas : [b2.rd, a.rd];
|
|
389
|
+
var out = b.network.dns.dnssec.verifyChain({ links: [{ zone: "test.", dnskeys: ordered, dnskeyRrsig: rrsig }], trustAnchors: [anchor], at: new Date((now) * 1000) });
|
|
390
|
+
check("verifyChain: validates despite a colliding-tag key in the signed set", out.ok === true);
|
|
391
|
+
}
|
|
392
|
+
|
|
273
393
|
async function run() {
|
|
274
394
|
testSurface();
|
|
275
395
|
testRealVectors();
|
|
276
396
|
testRefusals();
|
|
277
397
|
testVerifyDs();
|
|
398
|
+
testVerifyChain();
|
|
278
399
|
testNsec3Real();
|
|
279
400
|
testNsec3Caps();
|
|
280
401
|
testNsec3OptOut();
|
package/package.json
CHANGED