@blamejs/blamejs-shop 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.4 (2026-05-25) — **Sign in with Google.** Customers can sign in with Google alongside passkeys. The account login page gains a Continue with Google button; the OIDC authorization-code flow (PKCE, state, nonce, ID-token verification) runs through the framework's OAuth adapter, and the verified identity becomes a shop session. Accounts are keyed on the provider's stable subject, and an existing account is only ever linked on an email the provider has verified — an unverified email that collides with an existing account is refused rather than linked. A cart built before signing in is adopted into the account, so checkout attaches the order to the customer. **Added:** *Google sign-in* — Mounts `/account/login/google` + `/account/auth/google/callback` when the operator sets `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, and `SHOP_ORIGIN`. The in-flight state (CSRF state + nonce + PKCE verifier) rides a sealed, /account-scoped, SameSite=Lax cookie; the callback verifies the state before exchanging the code. A forged or stale callback is dropped to the login page. On success the guest session cart is adopted into the account (`cart.setCustomer`), matching the passkey path. · *Federated identity model + safe account linking* — `customers.signInWithOIDC` resolves a verified sign-in to a customer: an existing `(provider, subject)` link, else — only on a provider-verified email — an existing account with that email, else a new account. It never links to an existing account on an unverified email (account-takeover defense). New `customer_oauth_identities` table (migration 0205) + `customers.byOAuthIdentity`.
12
+
13
+ - v0.1.2 (2026-05-25) — **Apple Pay and Google Pay express checkout.** The pay page now offers one-tap wallet checkout. Stripe's Express Checkout Element renders Apple Pay and Google Pay buttons above the card form on eligible devices, confirming the same PaymentIntent as the card path — so the webhook and order flow are unchanged. To turn the wallets on, the operator registers the shop's web domain with Stripe once via the admin API; Stripe performs Apple merchant validation and hosts the domain-association file, so no Apple Developer account is needed. **Added:** *Wallet buttons on the pay page* — The Stripe Express Checkout Element mounts above the card form and auto-renders Apple Pay / Google Pay (and Link) when the device and the shop's registered domain make them available. It stays hidden until Stripe reports an available wallet, and confirms the existing per-order PaymentIntent — the payment-completion path (webhook → order FSM) is identical to the card flow. · *Payment-method domain registration* — `POST /admin/payment-method-domains` (with `{ "domain_name": "shop.example.com" }`) registers a domain with Stripe to enable the wallets; `GET /admin/payment-method-domains` lists registered domains and their per-method status. The payment adapter gains `registerPaymentMethodDomain` + `listPaymentMethodDomains`. Apex, www, and each subdomain register separately; a live-mode registration also covers sandbox.
14
+
11
15
  - v0.1.1 (2026-05-25) — **Admin setup wizard + a browser-accessible admin console.** The admin is now reachable from a browser, not just the bearer-token JSON API. Sign in once at /admin by pasting the ADMIN_API_KEY and a sealed, /admin-scoped session cookie carries you through the guided setup wizard (shop name, contact email, default currency, support URL — saved to shop config) and the analytics dashboard. The shop name set in the wizard drives the storefront header, page titles, and the admin header. **Added:** *Browser admin sign-in* — `/admin` renders a sign-in form; pasting the ADMIN_API_KEY sets a sealed `shop_admin` session cookie (SameSite=Strict, scoped to /admin) so the rendered admin pages are reachable from a browser. The JSON API stays bearer-only; the dashboard accepts either the cookie or the bearer token. · *Setup wizard* — `/admin/setup` is a guided form for the shop's core identity — name, contact email, default currency, support URL — validated (ISO-4217 currency, RFC-shaped email, http(s) support URL) and saved to shop config. The landing nags until setup is complete; the shop name then drives the storefront and admin headers.
12
16
 
13
17
  - v0.1.0 (2026-05-25) — **Responsive cart + storefront cookie handling moved onto the framework primitive.** A storefront polish pass. The cart, order-confirmation, and account-history tables now reflow into stacked, labelled cards on phones and fit their column on wider screens — no more inner horizontal scroll. The quantity field reads clearly and accepts up to 99,999. The line actions (Update / Save for later / Remove) are compact with a clear hierarchy. Under the hood, all storefront cookie handling now composes the framework's cookie primitive (RFC 6265 parse/serialize plus vault-sealed read/write) instead of hand-built headers, and a malformed session cookie can no longer turn the cart — or any page that shows the cart count — into a 500. **Changed:** *Quantity field is readable and accepts up to 99,999* — The cart quantity input is wide enough to read a five-digit quantity, and the per-line maximum is raised to 99,999 (enforced on the server). · *Compact, clearly-ranked line actions* — Update is a small accent button, Save for later a quiet secondary, Remove a danger-tinted secondary — so the primary checkout call stays dominant. · *Cookie handling composes the framework primitive* — Session, authentication, WebAuthn-challenge, and payment cookies are all parsed and written through the framework cookie primitive (sealed read/write for the authenticated-session cookie), replacing hand-built Set-Cookie strings and manual header parsing. Cookie lifetimes are expressed through the framework's duration constants. **Fixed:** *Cart tables no longer scroll sideways* — The cart, order-confirmation, and account order-history tables reflow into one labelled card per row below 48rem and are sized to fit their column on wider layouts, so content is never trapped behind an inner horizontal scrollbar. · *Malformed session cookie no longer 500s the storefront* — A `shop_sid` cookie carrying a value that isn't a well-formed session id now reads as "no session" instead of reaching the cart lookup (which rejected it and surfaced a 500 on every page that renders the cart count). · *Account dashboard controls wrap on narrow screens* — The row of account actions (Wishlist / Saved for later / Recently viewed / Addresses / Returns / Sign out) now wraps instead of overflowing the viewport on a phone.
package/README.md CHANGED
@@ -57,12 +57,12 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
57
57
  | **`lib/pricing.js`** | Pure-function money math — `lineTotal`, `subtotal`, `totals`, `format`. Multi-currency refused, banker's-style rounding, locale-aware via `Intl.NumberFormat`. |
58
58
  | **`lib/tax.js`** | Operator-table adapter. Country / state / postal_prefix → rate_bps. Most-specific-first match, banker's rounding. Pluggable adapter shape for future Stripe Tax / TaxJar / Avalara. |
59
59
  | **`lib/shipping.js`** | Operator-table adapter. Services with zones (flat or per-gram + base + min/max), free-over-threshold, `digital_only` flag. |
60
- | **`lib/payment.js`** | Stripe adapter — verify webhook (HMAC-SHA256 via upstream `b.webhook.verify` alg `hmac-sha256-stripe`), create / retrieve / confirm / cancel PaymentIntent, refund. No `stripe` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
60
+ | **`lib/payment.js`** | Stripe adapter — verify webhook (HMAC-SHA256 via upstream `b.webhook.verify` alg `hmac-sha256-stripe`), create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains (Apple Pay + Google Pay enablement for the Express Checkout Element). No `stripe` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
61
61
  | **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
62
62
  | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates PaymentIntent + persists order in pending; `handleStripeEvent()` verifies webhook + fires the FSM transition (idempotent on re-delivery). |
63
63
  | **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
64
64
  | **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant — operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
65
- | **`lib/customers.js`** | Customer accounts — passkey-only (WebAuthn). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. Account routes (`/account/login`, `/account/register`, `/account`) ship as designed cards on the storefront. |
65
+ | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`) ship as designed cards on the storefront. |
66
66
  | **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
67
67
  | **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
68
68
  | **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
@@ -92,6 +92,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
92
92
  - `migrations-d1/0041_save_for_later.sql` — per-customer cart holding list (price snapshot + source line)
93
93
  - `migrations-d1/0026_customer_addresses.sql` — per-customer address book (default shipping/billing flags)
94
94
  - `migrations-d1/0023_returns.sql` — return authorizations + lines (RMA lifecycle FSM)
95
+ - `migrations-d1/0205_customer_oauth_identities.sql` — federated sign-in identities (provider + subject, verified-email gating)
95
96
  - `migrations-d1/0043_collections.sql` — manual + smart product collections (members + rules + sort strategy)
96
97
  - `migrations-d1/0050_recently_viewed.sql` — per-customer / per-session product browse history (dedup + per-subject cap)
97
98
 
package/lib/admin.js CHANGED
@@ -789,6 +789,31 @@ function mount(router, deps) {
789
789
  }));
790
790
  }
791
791
 
792
+ // ---- payment-method domains (Apple Pay / Google Pay enablement) -----
793
+ //
794
+ // Registering the shop's web domain with Stripe enables the wallet
795
+ // methods for the Express Checkout Element on the pay page. Stripe
796
+ // performs Apple merchant validation + hosts the association file, so
797
+ // there's no Apple Developer account to wire — this is the operator's
798
+ // one-shot action. Disabled when the payment dep is absent.
799
+ if (payment) {
800
+ router.post("/admin/payment-method-domains", W("payment_domain.register", async function (req, res) {
801
+ var body = req.body || {};
802
+ var domainName = body.domain_name;
803
+ var result = await payment.registerPaymentMethodDomain(domainName);
804
+ _json(res, 201, result);
805
+ return { id: (result && result.id) || domainName };
806
+ }));
807
+
808
+ router.get("/admin/payment-method-domains", R(async function (req, res) {
809
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
810
+ var domainName = url && url.searchParams.get("domain_name");
811
+ var filter = domainName ? { domain_name: domainName } : {};
812
+ var result = await payment.listPaymentMethodDomains(filter);
813
+ _json(res, 200, result);
814
+ }));
815
+ }
816
+
792
817
  // ---- webhooks -------------------------------------------------------
793
818
 
794
819
  var webhooks = deps.webhooks || null;
package/lib/customers.js CHANGED
@@ -36,6 +36,8 @@ function _b() {
36
36
  }
37
37
 
38
38
  var EMAIL_NAMESPACE = "customer-email";
39
+ // Federated identity providers the OIDC sign-in path accepts.
40
+ var OAUTH_PROVIDERS = ["google", "apple"];
39
41
  var MAX_DISPLAY_NAME_LEN = 128;
40
42
  var MAX_CRED_FIELD_BYTES = 4096;
41
43
  var TRANSPORTS_RE = /^[a-z]+(?:,[a-z]+)*$/;
@@ -144,7 +146,7 @@ function create(opts) {
144
146
  return _b().crypto.namespaceHash(EMAIL_NAMESPACE, email);
145
147
  }
146
148
 
147
- return {
149
+ var api = {
148
150
  EMAIL_NAMESPACE: EMAIL_NAMESPACE,
149
151
 
150
152
  // Hash an email without writing — useful for login lookups
@@ -202,6 +204,102 @@ function create(opts) {
202
204
  return r.rows[0] || null;
203
205
  },
204
206
 
207
+ // ---- federated (OAuth / OIDC) sign-in -------------------------------
208
+
209
+ // The customer linked to an external (provider, subject) identity, or
210
+ // null. `subject` is the IdP `sub` claim — the durable account key.
211
+ byOAuthIdentity: async function (provider, subject) {
212
+ if (OAUTH_PROVIDERS.indexOf(provider) === -1) {
213
+ throw new TypeError("customers.byOAuthIdentity: unknown provider " + JSON.stringify(provider));
214
+ }
215
+ if (typeof subject !== "string" || !subject.length || subject.length > 255) {
216
+ throw new TypeError("customers.byOAuthIdentity: subject must be a non-empty string ≤ 255 chars");
217
+ }
218
+ var r = await query(
219
+ "SELECT c.* FROM customers c " +
220
+ "JOIN customer_oauth_identities i ON i.customer_id = c.id " +
221
+ "WHERE i.provider = ?1 AND i.subject = ?2 LIMIT 1",
222
+ [provider, subject],
223
+ );
224
+ return r.rows[0] || null;
225
+ },
226
+
227
+ // Resolve (or create) the customer for a verified OIDC sign-in.
228
+ // Resolution order, per standard federated-identity discipline:
229
+ // 1. an existing (provider, subject) link → that customer (the
230
+ // durable key; email changes don't break it);
231
+ // 2. else, ONLY when the IdP verified the email, an existing
232
+ // customer with that email_hash → link the identity to it;
233
+ // 3. else register a new customer + link.
234
+ // Never links to an existing account on an UNVERIFIED email (that
235
+ // would be account takeover). Returns { customer, created,
236
+ // linked_via }.
237
+ signInWithOIDC: async function (input) {
238
+ if (!input || typeof input !== "object") {
239
+ throw new TypeError("customers.signInWithOIDC: input object required");
240
+ }
241
+ var provider = input.provider;
242
+ if (OAUTH_PROVIDERS.indexOf(provider) === -1) {
243
+ throw new TypeError("customers.signInWithOIDC: unknown provider " + JSON.stringify(provider));
244
+ }
245
+ var subject = input.subject;
246
+ if (typeof subject !== "string" || !subject.length || subject.length > 255) {
247
+ throw new TypeError("customers.signInWithOIDC: subject must be a non-empty string ≤ 255 chars");
248
+ }
249
+ var emailVerified = input.email_verified === true;
250
+ var canonicalEmail = input.email ? _normalizeEmail(input.email) : null;
251
+ var ts = _now();
252
+
253
+ // (1) Existing federated link — refresh the captured email + flag.
254
+ var existing = await api.byOAuthIdentity(provider, subject);
255
+ if (existing) {
256
+ await query(
257
+ "UPDATE customer_oauth_identities SET email = ?1, email_verified = ?2, updated_at = ?3 " +
258
+ "WHERE provider = ?4 AND subject = ?5",
259
+ [canonicalEmail, emailVerified ? 1 : 0, ts, provider, subject],
260
+ );
261
+ return { customer: existing, created: false, linked_via: "oauth-subject" };
262
+ }
263
+
264
+ // (2) Link to an existing customer — verified email only.
265
+ var customer = null, linkedVia = null, created = false;
266
+ if (emailVerified && canonicalEmail) {
267
+ customer = await api.byEmailHash(_hashEmail(canonicalEmail));
268
+ if (customer) linkedVia = "verified-email";
269
+ }
270
+
271
+ // (3) New account. Reaching here with a duplicate email means the
272
+ // address already belongs to an account but step (2) declined to
273
+ // link it — i.e. the IdP did NOT verify the email. Refuse rather
274
+ // than link (account takeover) or create a colliding row.
275
+ if (!customer) {
276
+ if (!canonicalEmail) {
277
+ throw new TypeError("customers.signInWithOIDC: an email is required to create a new account");
278
+ }
279
+ var displayName = input.display_name || canonicalEmail.split("@")[0] || "Customer";
280
+ try {
281
+ customer = await api.register({ email: canonicalEmail, display_name: displayName });
282
+ } catch (e) {
283
+ if (e && e.code === "CUSTOMER_DUPLICATE") {
284
+ var conflict = new Error("customers.signInWithOIDC: that email is registered to another account and " + provider + " has not verified it — sign in with your existing method or verify the email first");
285
+ conflict.code = "OAUTH_EMAIL_UNVERIFIED_CONFLICT";
286
+ throw conflict;
287
+ }
288
+ throw e;
289
+ }
290
+ linkedVia = "new";
291
+ created = true;
292
+ }
293
+
294
+ await query(
295
+ "INSERT INTO customer_oauth_identities " +
296
+ "(id, customer_id, provider, subject, email, email_verified, created_at, updated_at) " +
297
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
298
+ [_b().uuid.v7(), customer.id, provider, subject, canonicalEmail, emailVerified ? 1 : 0, ts],
299
+ );
300
+ return { customer: customer, created: created, linked_via: linkedVia };
301
+ },
302
+
205
303
  listPasskeys: async function (customerId) {
206
304
  _uuid(customerId, "customer id");
207
305
  var r = await query(
@@ -312,6 +410,7 @@ function create(opts) {
312
410
  return true;
313
411
  },
314
412
  };
413
+ return api;
315
414
  }
316
415
 
317
416
  module.exports = {
package/lib/payment.js CHANGED
@@ -370,6 +370,33 @@ function stripe(opts) {
370
370
  {}, idempotencyKey);
371
371
  },
372
372
 
373
+ // Register a web domain so Stripe enables the wallet methods (Apple
374
+ // Pay / Google Pay / Link / PayPal) for the Express Checkout Element
375
+ // served from it. Stripe performs Apple merchant validation + hosts
376
+ // the domain-association file — the operator does not need an Apple
377
+ // Developer account. Registering in live mode also registers
378
+ // sandbox. One-shot operator action (admin endpoint). `domainName`
379
+ // is a bare hostname — apex, www, and subdomains register separately.
380
+ registerPaymentMethodDomain: function (domainName, idempotencyKey) {
381
+ if (typeof domainName !== "string" ||
382
+ !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(domainName)) {
383
+ throw new TypeError("payment.registerPaymentMethodDomain: domainName must be a bare hostname (no scheme / path / port)");
384
+ }
385
+ return _stripeCall(opts, "POST", "/payment_method_domains", { domain_name: domainName }, idempotencyKey);
386
+ },
387
+
388
+ // List registered payment-method domains (optionally filtered to one
389
+ // hostname) with each method's enablement status. Read-only.
390
+ listPaymentMethodDomains: function (filter) {
391
+ filter = filter || {};
392
+ var path = "/payment_method_domains";
393
+ if (filter.domain_name) {
394
+ if (typeof filter.domain_name !== "string") throw new TypeError("payment.listPaymentMethodDomains: domain_name must be a string");
395
+ path += "?domain_name=" + encodeURIComponent(filter.domain_name);
396
+ }
397
+ return _stripeCall(opts, "GET", path, null, null);
398
+ },
399
+
373
400
  refund: function (input, idempotencyKey) {
374
401
  if (!input || typeof input !== "object") throw new TypeError("payment.refund: input object required");
375
402
  _assertSecret(input.payment_intent, "refund.payment_intent");
package/lib/storefront.js CHANGED
@@ -1617,6 +1617,10 @@ var PAY_PAGE =
1617
1617
  " <p class=\"section-head__lede\">Order <code class=\"inline-code\">{{order_id}}</code> · the Stripe Payment Element is mounted below in a same-origin form.</p>\n" +
1618
1618
  " </header>\n" +
1619
1619
  " <div class=\"pay-card\">\n" +
1620
+ " <div id=\"express-checkout\" class=\"pay-card__express\" hidden>\n" +
1621
+ " <div id=\"express-checkout-element\"></div>\n" +
1622
+ " <div class=\"pay-card__divider\"><span>or pay with card</span></div>\n" +
1623
+ " </div>\n" +
1620
1624
  " <div id=\"payment-element\" class=\"pay-card__element\"></div>\n" +
1621
1625
  " <button id=\"submit\" type=\"button\" class=\"btn-primary pay-card__submit\">Pay {{grand_total}}</button>\n" +
1622
1626
  " <p id=\"payment-message\" class=\"pay-card__message\"></p>\n" +
@@ -1626,14 +1630,29 @@ var PAY_PAGE =
1626
1630
  " (function () {\n" +
1627
1631
  " var stripe = Stripe({{pk_json}});\n" +
1628
1632
  " var elements = stripe.elements({ clientSecret: {{client_secret_json}}, appearance: { theme: \"stripe\" } });\n" +
1629
- " var paymentElement = elements.create(\"payment\");\n" +
1630
- " paymentElement.mount(\"#payment-element\");\n" +
1631
- " document.getElementById(\"submit\").addEventListener(\"click\", function () {\n" +
1632
- " document.getElementById(\"payment-message\").textContent = \"Processing...\";\n" +
1633
- " stripe.confirmPayment({ elements: elements, confirmParams: { return_url: window.location.origin + \"/orders/{{order_id}}\" } }).then(function (result) {\n" +
1634
- " if (result.error) { document.getElementById(\"payment-message\").textContent = result.error.message || \"Payment failed.\"; }\n" +
1633
+ " var returnUrl = window.location.origin + \"/orders/{{order_id}}\";\n" +
1634
+ " var message = document.getElementById(\"payment-message\");\n" +
1635
+ " function confirm() {\n" +
1636
+ " message.textContent = \"Processing...\";\n" +
1637
+ " return stripe.confirmPayment({ elements: elements, confirmParams: { return_url: returnUrl } }).then(function (result) {\n" +
1638
+ " if (result.error) { message.textContent = result.error.message || \"Payment failed.\"; }\n" +
1635
1639
  " });\n" +
1640
+ " }\n" +
1641
+ " // Express Checkout Element — renders Apple Pay / Google Pay /\n" +
1642
+ " // Link wallet buttons when the device + the shop's registered\n" +
1643
+ " // payment-method domain make them available. It confirms the\n" +
1644
+ " // same PaymentIntent as the card form, so the webhook + order\n" +
1645
+ " // FSM are identical. Hidden until Stripe reports an available\n" +
1646
+ " // wallet so the divider never sits over an empty box.\n" +
1647
+ " var ece = elements.create(\"expressCheckout\");\n" +
1648
+ " ece.on(\"ready\", function (ev) {\n" +
1649
+ " if (ev && ev.availablePaymentMethods) { document.getElementById(\"express-checkout\").hidden = false; }\n" +
1636
1650
  " });\n" +
1651
+ " ece.on(\"confirm\", function () { confirm(); });\n" +
1652
+ " ece.mount(\"#express-checkout-element\");\n" +
1653
+ " var paymentElement = elements.create(\"payment\");\n" +
1654
+ " paymentElement.mount(\"#payment-element\");\n" +
1655
+ " document.getElementById(\"submit\").addEventListener(\"click\", function () { confirm(); });\n" +
1637
1656
  " })();\n" +
1638
1657
  " </script>\n" +
1639
1658
  "</section>\n";
@@ -2103,6 +2122,12 @@ var CHALLENGE_COOKIE_NAME = "shop_auth_chal";
2103
2122
  // SameSite=Strict so it's only ever sent to the pay route.
2104
2123
  var PAY_COOKIE_NAME = "shop_pay";
2105
2124
 
2125
+ // Short-lived sealed cookie holding the in-flight OIDC sign-in state
2126
+ // (provider + CSRF state + nonce + PKCE verifier) between the redirect
2127
+ // to the identity provider and the callback. Path-scoped to /account;
2128
+ // SameSite=Lax so it survives the provider's top-level GET redirect back.
2129
+ var OAUTH_COOKIE_NAME = "shop_oauth";
2130
+
2106
2131
  // Shape of a valid session id — mirrors cart.js's SESSION_ID_RE.
2107
2132
  var SID_SHAPE_RE = /^[A-Za-z0-9_-]{16,64}$/;
2108
2133
 
@@ -2180,11 +2205,13 @@ var ACCOUNT_LOGIN_PAGE =
2180
2205
  " <p class=\"eyebrow\">Sign in</p>\n" +
2181
2206
  " <h1 class=\"auth-card__title\">Welcome back</h1>\n" +
2182
2207
  " <p class=\"auth-card__lede\">Enter your email and authenticate with your passkey. No password to type, no recovery email to click.</p>\n" +
2208
+ " RAW_LOGIN_ERROR\n" +
2183
2209
  " <form id=\"login-form\" method=\"post\" class=\"form-stack auth-form\">\n" +
2184
2210
  " <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span><input type=\"email\" name=\"email\" id=\"email\" required autocomplete=\"email\" autofocus></label></div>\n" +
2185
2211
  " <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Sign in with passkey</button></div>\n" +
2186
2212
  " <p id=\"login-message\" class=\"auth-form__message\"></p>\n" +
2187
2213
  " </form>\n" +
2214
+ " RAW_LOGIN_OAUTH\n" +
2188
2215
  " <p class=\"auth-card__alt\">New here? <a href=\"/account/register\">Create an account →</a></p>\n" +
2189
2216
  " </div>\n" +
2190
2217
  " <script>\n" +
@@ -2211,14 +2238,31 @@ var ACCOUNT_LOGIN_PAGE =
2211
2238
  " </script>\n" +
2212
2239
  "</section>\n";
2213
2240
 
2241
+ var LOGIN_ERROR_MESSAGES = {
2242
+ oauth: "We couldn't complete that sign-in. Please try again.",
2243
+ "email-conflict": "That email already has an account — sign in with your passkey instead.",
2244
+ };
2245
+
2214
2246
  function renderAccountLogin(opts) {
2215
2247
  opts = opts || {};
2248
+ var oauthHtml = opts.google_enabled
2249
+ ? "<div class=\"auth-oauth\">" +
2250
+ "<div class=\"auth-oauth__divider\"><span>or</span></div>" +
2251
+ "<a class=\"btn-secondary auth-oauth__btn\" href=\"/account/login/google\">Continue with Google</a>" +
2252
+ "</div>"
2253
+ : "";
2254
+ var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
2255
+ ? "<p class=\"auth-form__message auth-form__message--err\">" + _b().template.escapeHtml(LOGIN_ERROR_MESSAGES[opts.error]) + "</p>"
2256
+ : "";
2257
+ var body = ACCOUNT_LOGIN_PAGE
2258
+ .replace("RAW_LOGIN_OAUTH", oauthHtml)
2259
+ .replace("RAW_LOGIN_ERROR", errHtml);
2216
2260
  return _wrap({
2217
2261
  title: "Sign in",
2218
2262
  shop_name: opts.shop_name || "blamejs.shop",
2219
2263
  cart_count: opts.cart_count,
2220
2264
  theme_css: opts.theme_css,
2221
- body: ACCOUNT_LOGIN_PAGE,
2265
+ body: body,
2222
2266
  });
2223
2267
  }
2224
2268
 
@@ -2888,7 +2932,13 @@ function mount(router, deps) {
2888
2932
 
2889
2933
  router.get("/account/login", async function (req, res) {
2890
2934
  var cartCount = await _cartCountForReq(req);
2891
- _send(res, 200, renderAccountLogin({ shop_name: shopName, cart_count: cartCount }));
2935
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
2936
+ _send(res, 200, renderAccountLogin({
2937
+ shop_name: shopName,
2938
+ cart_count: cartCount,
2939
+ google_enabled: !!deps.oauthGoogle,
2940
+ error: url && url.searchParams.get("error"),
2941
+ }));
2892
2942
  });
2893
2943
 
2894
2944
  router.get("/account/register", async function (req, res) {
@@ -3169,6 +3219,87 @@ function mount(router, deps) {
3169
3219
  return res.end ? res.end() : res.send("");
3170
3220
  });
3171
3221
 
3222
+ // Sign in with Google (OIDC). Mounts when the operator wires an
3223
+ // `oauthGoogle` adapter (b.auth.oauth, google preset). The framework
3224
+ // adapter owns discovery + PKCE + ID-token verification (signature,
3225
+ // iss, aud, exp, nonce); this layer manages the sealed in-flight
3226
+ // state cookie and turns the verified identity into a shop session
3227
+ // via customers.signInWithOIDC (which gates account linking on a
3228
+ // verified email).
3229
+ if (deps.oauthGoogle) {
3230
+ router.get("/account/login/google", async function (req, res) {
3231
+ try {
3232
+ var a = await deps.oauthGoogle.authorizationUrl({ prompt: "select_account" });
3233
+ _cookieJar().writeSealed(res, OAUTH_COOKIE_NAME, JSON.stringify({
3234
+ provider: "google", state: a.state, nonce: a.nonce, verifier: a.verifier,
3235
+ }), { expires: new Date(Date.now() + _b().constants.TIME.minutes(10)), path: "/account", sameSite: "Lax" });
3236
+ res.status(302);
3237
+ res.setHeader && res.setHeader("location", a.url);
3238
+ return res.end ? res.end() : res.send("");
3239
+ } catch (e) {
3240
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return; }
3241
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login?error=oauth");
3242
+ return res.end ? res.end() : res.send("");
3243
+ }
3244
+ });
3245
+
3246
+ router.get("/account/auth/google/callback", async function (req, res) {
3247
+ function _toLogin(err) {
3248
+ res.status(303);
3249
+ res.setHeader && res.setHeader("location", "/account/login" + (err ? "?error=" + err : ""));
3250
+ return res.end ? res.end() : res.send("");
3251
+ }
3252
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3253
+ var code = url && url.searchParams.get("code");
3254
+ var state = url && url.searchParams.get("state");
3255
+ if (!code || !state) return _toLogin("oauth");
3256
+
3257
+ // Recover + clear the sealed sign-in state; the CSRF state must
3258
+ // match the value we issued (a forged callback is dropped here).
3259
+ var saved;
3260
+ try { var raw = _cookieJar().readSealed(req, OAUTH_COOKIE_NAME); saved = raw ? JSON.parse(raw) : null; }
3261
+ catch (_e) { saved = null; }
3262
+ _cookieJar().clear(res, OAUTH_COOKIE_NAME, { path: "/account" });
3263
+ if (!saved || saved.provider !== "google" || saved.state !== state) return _toLogin("oauth");
3264
+
3265
+ var claims;
3266
+ try {
3267
+ var tokens = await deps.oauthGoogle.exchangeCode({ code: code, verifier: saved.verifier, nonce: saved.nonce });
3268
+ claims = tokens && tokens.claims;
3269
+ } catch (_e) { return _toLogin("oauth"); }
3270
+ if (!claims || !claims.sub) return _toLogin("oauth");
3271
+
3272
+ var rv;
3273
+ try {
3274
+ rv = await deps.customers.signInWithOIDC({
3275
+ provider: "google",
3276
+ subject: String(claims.sub),
3277
+ email: claims.email,
3278
+ email_verified: claims.email_verified === true,
3279
+ display_name: claims.name,
3280
+ });
3281
+ } catch (e) {
3282
+ if (e && e.code === "OAUTH_EMAIL_UNVERIFIED_CONFLICT") return _toLogin("email-conflict");
3283
+ if (e instanceof TypeError) return _toLogin("oauth");
3284
+ throw e;
3285
+ }
3286
+ // Adopt the guest cart into the now-authenticated account so a
3287
+ // cart built before sign-in isn't lost — and so checkout.confirm
3288
+ // (which derives order.customer_id from cart.customer_id) attaches
3289
+ // the order to the customer. Mirrors the passkey login path.
3290
+ var sid = _readSidCookie(req);
3291
+ if (sid) {
3292
+ try {
3293
+ var anonCart = await deps.cart.bySession(sid);
3294
+ if (anonCart) await deps.cart.setCustomer(anonCart.id, rv.customer.id);
3295
+ } catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
3296
+ }
3297
+ _setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + _b().constants.TIME.days(14) });
3298
+ res.status(303); res.setHeader && res.setHeader("location", "/account");
3299
+ return res.end ? res.end() : res.send("");
3300
+ });
3301
+ }
3302
+
3172
3303
  // Wishlist — saved products scoped to the logged-in customer.
3173
3304
  // Mounts when the wishlist primitive is wired.
3174
3305
  if (deps.wishlist) {
@@ -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.48",
7
- "tag": "v0.12.48",
6
+ "version": "0.12.49",
7
+ "tag": "v0.12.49",
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.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
+
11
13
  - 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.
12
14
 
13
15
  - v0.12.47 (2026-05-25) — **`b.cose.mac0` / `b.cose.macVerify0` — COSE_Mac0 (RFC 9052 §6.2).** Completes the COSE message-type set (COSE_Sign1 / COSE_Encrypt0 / COSE_Mac0) with single shared-key MACs. b.cose.mac0 produces a tagged COSE_Mac0 over a payload using HMAC-SHA-256/384/512 (the COSE-standard MAC algorithms; HMAC is symmetric, so its post-quantum strength is preserved). b.cose.macVerify0 recomputes the tag over the MAC_structure and compares it in constant time, with a mandatory algorithm allowlist. Use when both parties hold a shared key — e.g. an ECDH-derived key — and a non-repudiable signature is not wanted; detached payloads are supported (the proximity mdoc device-MAC variant and MACed CWTs are the consumers). Composes b.cbor + the framework's constant-time compare; no new runtime dependency. **Added:** *`b.cose.mac0(payload, opts)` / `b.cose.macVerify0(coseMac0, opts)`* — `mac0` emits a tagged COSE_Mac0 (tag 17) with `alg` (`HMAC-256/256` | `HMAC-384/384` | `HMAC-512/512`) in the protected header and the HMAC tag computed over the MAC_structure `["MAC0", protected, external_aad, payload]`; `detached: true` emits a nil payload. `macVerify0` reads the algorithm from the protected header (must be in the required `opts.algorithms` allowlist), recomputes the tag, and compares it constant-time — a wrong key, tampered tag, or `external_aad` mismatch is refused with `cose/bad-tag`; a detached payload is supplied via `opts.externalPayload`. `external_aad` binds context into the tag.
@@ -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) so a resolver client can verify an answer 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) 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 DNSKEY against the parent's DS with `b.network.dns.dnssec.verifyDs(...)` up the chain to a trust anchor you pin
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 DNSKEY against the parent's DS with `b.network.dns.dnssec.verifyDs(...)` up the chain to a trust anchor you pin. 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.48",
4
- "createdAt": "2026-05-25T11:29:28.593Z",
3
+ "frameworkVersion": "0.12.49",
4
+ "createdAt": "2026-05-25T12:28:18.912Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -41682,6 +41682,14 @@
41682
41682
  "type": "function",
41683
41683
  "arity": 1
41684
41684
  },
41685
+ "nsec3Hash": {
41686
+ "type": "function",
41687
+ "arity": 2
41688
+ },
41689
+ "verifyDenial": {
41690
+ "type": "function",
41691
+ "arity": 1
41692
+ },
41685
41693
  "verifyDs": {
41686
41694
  "type": "function",
41687
41695
  "arity": 1
@@ -60,16 +60,21 @@ var ALGS = {
60
60
  // DS digest algorithms (IANA) → node hash.
61
61
  var DS_DIGESTS = { 2: "sha256", 4: "sha384" };
62
62
 
63
- // RR types whose RDATA contains NO embedded domain name, so the wire
64
- // RDATA is already in canonical form (RFC 4034 §6.2 needs no rewrite).
65
- // Name-bearing types are refused rather than silently mis-canonicalised.
63
+ // RR types whose RDATA contains NO embedded domain name that needs
64
+ // downcasing, so the wire RDATA is already in canonical form (RFC 4034
65
+ // §6.2 needs no rewrite). Name-bearing types are refused rather than
66
+ // silently mis-canonicalised. NSEC (47) is included because RFC 6840
67
+ // §5.1 corrected RFC 4034 §6.2: the NSEC Next Domain Name field is NOT
68
+ // downcased for DNSSEC canonical form, so its uncompressed RDATA is
69
+ // verbatim-canonical. NSEC3 (50) carries a hashed next-owner, not a name.
66
70
  // (type numbers IANA): A 1, AAAA 28, TXT 16, DNSKEY 48, DS 43, CAA 257,
67
- // TLSA 52, SSHFP 44, HINFO 13, CDS 59, CDNSKEY 60, OPENPGPKEY 61, SMIMEA 53.
68
- var NAME_FREE_TYPE_NUMS = [1, 28, 16, 48, 43, 257, 52, 44, 13, 59, 60, 61, 53]; // allow:raw-byte-literal allow:raw-time-literal — IANA DNS type numbers (no embedded names)
71
+ // TLSA 52, SSHFP 44, HINFO 13, CDS 59, CDNSKEY 60, OPENPGPKEY 61, SMIMEA
72
+ // 53, NSEC 47, NSEC3 50.
73
+ var NAME_FREE_TYPE_NUMS = [1, 28, 16, 48, 43, 257, 52, 44, 13, 59, 60, 61, 53, 47, 50]; // allow:raw-byte-literal allow:raw-time-literal — IANA DNS type numbers (no downcased embedded names)
69
74
  var TYPE_NUM = {
70
75
  A: 1, NS: 2, CNAME: 5, SOA: 6, PTR: 12, MX: 15, TXT: 16, AAAA: 28, SRV: 33,
71
- DS: 43, SSHFP: 44, RRSIG: 46, DNSKEY: 48, TLSA: 52, SMIMEA: 53, CDS: 59, CDNSKEY: 60, // allow:raw-byte-literal allow:raw-time-literal — IANA DNS type numbers
72
- OPENPGPKEY: 61, CAA: 257, HINFO: 13,
76
+ DS: 43, SSHFP: 44, RRSIG: 46, NSEC: 47, DNSKEY: 48, NSEC3: 50, TLSA: 52, // allow:raw-byte-literal allow:raw-time-literal — IANA DNS type numbers
77
+ SMIMEA: 53, CDS: 59, CDNSKEY: 60, OPENPGPKEY: 61, CAA: 257, HINFO: 13,
73
78
  };
74
79
 
75
80
  function _bytes(x, what) {
@@ -319,10 +324,409 @@ function verifyRrset(opts) {
319
324
  return { ok: true, algorithm: alg.name, keyTag: rrsig.keyTag, signerName: rrsig.signerName };
320
325
  }
321
326
 
327
+ // ---------------------------------------------------------------------------
328
+ // Denial of existence (RFC 4034 §4 NSEC, RFC 5155 NSEC3).
329
+ //
330
+ // These helpers prove a name (or a name+type) DOES NOT EXIST from the
331
+ // signed NSEC / NSEC3 records a server returns in the Authority section.
332
+ // They operate on records the caller has ALREADY verified with
333
+ // verifyRrset — like verifyDs, this is the relation check, not the
334
+ // signature check. Passing unverified records proves nothing.
335
+ // ---------------------------------------------------------------------------
336
+
337
+ var BASE32HEX = "0123456789ABCDEFGHIJKLMNOPQRSTUV"; // allow:raw-byte-literal — RFC 4648 §7 extended-hex alphabet (RFC number in comment)
338
+ var TYPE_DS = 43; // allow:raw-byte-literal — IANA RR type DS
339
+ var TYPE_CNAME = 5;
340
+ var NSEC3_HASH_SHA1 = 1; // RFC 5155 §5 — the only registered NSEC3 hash
341
+ var DEFAULT_MAX_NSEC3_ITERATIONS = 500; // allow:raw-time-literal — DoS ceiling on iterated SHA-1 (RFC 9276 wants 0; deployed zones still use >0)
342
+
343
+ // RFC 4648 §7 base32hex decode (no padding, case-insensitive) — the
344
+ // label encoding of an NSEC3 owner-name hash.
345
+ function _base32hexDecode(s, label) {
346
+ var up = String(s).toUpperCase();
347
+ var bits = 0, value = 0, out = [];
348
+ for (var i = 0; i < up.length; i++) {
349
+ var idx = BASE32HEX.indexOf(up[i]);
350
+ if (idx === -1) throw new DnssecError("dnssec/bad-nsec3", "dnssec: " + label + " is not valid base32hex");
351
+ value = (value << 5) | idx; // allow:raw-byte-literal — base32 5-bit group
352
+ bits += 5; // allow:raw-byte-literal — base32 5-bit group
353
+ if (bits >= 8) { bits -= 8; out.push((value >> bits) & 0xff); } // allow:raw-byte-literal — emit a full octet
354
+ }
355
+ return Buffer.from(out);
356
+ }
357
+
358
+ // RFC 4034 §4.1.2 / RFC 5155 §3.2.1 type bitmap → Set of type numbers.
359
+ function _parseTypeBitmaps(buf, off, end) {
360
+ var types = new Set();
361
+ var i = off;
362
+ while (i + 2 <= end) {
363
+ var win = buf[i], len = buf[i + 1];
364
+ i += 2;
365
+ if (len < 1 || len > 32 || i + len > end) { // allow:raw-byte-literal — bitmap window ≤ 256 bits = 32 octets (RFC 4034 §4.1.2)
366
+ throw new DnssecError("dnssec/bad-bitmap", "dnssec: malformed NSEC type bitmap");
367
+ }
368
+ for (var j = 0; j < len; j++) {
369
+ var octet = buf[i + j];
370
+ for (var bit = 0; bit < 8; bit++) { // allow:raw-byte-literal — 8 bits per octet
371
+ if (octet & (0x80 >> bit)) types.add(win * 256 + j * 8 + bit); // allow:raw-byte-literal — bit→type-number (window*256 + octet*8 + bit)
372
+ }
373
+ }
374
+ i += len;
375
+ }
376
+ return types;
377
+ }
378
+
379
+ // Read an uncompressed wire-format domain name (compression pointers are
380
+ // illegal in signed RDATA). Returns { name, end }.
381
+ function _readWireName(buf, off) {
382
+ var labels = [];
383
+ var i = off;
384
+ for (;;) {
385
+ if (i >= buf.length) throw new DnssecError("dnssec/bad-name", "dnssec: truncated name in RDATA");
386
+ var len = buf[i];
387
+ if (len === 0) { i++; break; }
388
+ if ((len & 0xc0) !== 0) throw new DnssecError("dnssec/bad-name", "dnssec: compression pointer in signed RDATA"); // allow:raw-byte-literal — RFC 1035 label-length top-two-bits flag
389
+ i++;
390
+ labels.push(buf.slice(i, i + len).toString("ascii"));
391
+ i += len;
392
+ }
393
+ return { name: labels.length ? labels.join(".") + "." : ".", end: i };
394
+ }
395
+
396
+ function _parseNsec3Rdata(rd) {
397
+ if (rd.length < 6) throw new DnssecError("dnssec/bad-nsec3", "dnssec: NSEC3 RDATA too short"); // allow:raw-byte-literal — fixed NSEC3 header octets
398
+ var hashAlg = rd[0], flags = rd[1], iterations = rd.readUInt16BE(2), saltLen = rd[4];
399
+ var off = 5 + saltLen;
400
+ if (off + 1 > rd.length) throw new DnssecError("dnssec/bad-nsec3", "dnssec: NSEC3 salt overruns RDATA");
401
+ var salt = rd.slice(5, 5 + saltLen);
402
+ var hashLen = rd[off]; off += 1;
403
+ if (off + hashLen > rd.length) throw new DnssecError("dnssec/bad-nsec3", "dnssec: NSEC3 next-hashed-owner overruns RDATA");
404
+ var nextHashed = rd.slice(off, off + hashLen);
405
+ return { hashAlg: hashAlg, flags: flags, iterations: iterations, salt: salt, nextHashed: nextHashed, types: _parseTypeBitmaps(rd, off + hashLen, rd.length) };
406
+ }
407
+
408
+ function _parseNsecRdata(rd) {
409
+ var n = _readWireName(rd, 0);
410
+ return { nextName: n.name, types: _parseTypeBitmaps(rd, n.end, rd.length) };
411
+ }
412
+
413
+ // RFC 5155 §5 iterated hash: IH(salt, x, 0)=SHA-1(x‖salt);
414
+ // IH(salt, x, k)=SHA-1(IH(salt,x,k-1)‖salt). SHA-1 is the only NSEC3
415
+ // hash IANA defines — a wire-protocol constant, not a framework default.
416
+ function _nsec3HashWire(nameWire, salt, iterations) {
417
+ var h = nodeCrypto.createHash("sha1").update(Buffer.concat([nameWire, salt])).digest();
418
+ for (var k = 0; k < iterations; k++) {
419
+ h = nodeCrypto.createHash("sha1").update(Buffer.concat([h, salt])).digest();
420
+ }
421
+ return h;
422
+ }
423
+
424
+ function _nameLabels(name) {
425
+ var n = String(name).replace(/\.$/, "");
426
+ return n === "" ? [] : n.split(".");
427
+ }
428
+
429
+ // RFC 4034 §6.1 canonical name ordering: compare label sequences from
430
+ // the least-significant (rightmost) label, octets lowercased.
431
+ function _canonicalNameCompare(a, b) {
432
+ var la = _nameLabels(a).reverse(), lb = _nameLabels(b).reverse();
433
+ var min = Math.min(la.length, lb.length);
434
+ for (var i = 0; i < min; i++) {
435
+ var c = Buffer.compare(Buffer.from(la[i].toLowerCase(), "ascii"), Buffer.from(lb[i].toLowerCase(), "ascii"));
436
+ if (c !== 0) return c;
437
+ }
438
+ return la.length - lb.length;
439
+ }
440
+
441
+ // Closest-encloser candidates: proper suffixes of qname from longest
442
+ // (qname minus one label) down to the zone apex, longest first.
443
+ function _closestEncloserCandidates(qname, zone) {
444
+ var ql = _nameLabels(qname), zl = _nameLabels(zone);
445
+ var out = [];
446
+ for (var k = ql.length - 1; k >= zl.length; k--) {
447
+ out.push(ql.slice(ql.length - k).join(".") + ".");
448
+ }
449
+ return out;
450
+ }
451
+
452
+ // The "next closer" name: the closest encloser with one more label of
453
+ // qname prepended (RFC 5155 §1.3).
454
+ function _nextCloser(qname, ce) {
455
+ var ql = _nameLabels(qname), n = _nameLabels(ce).length + 1;
456
+ return ql.slice(ql.length - n).join(".") + ".";
457
+ }
458
+
459
+ /**
460
+ * @primitive b.network.dns.dnssec.nsec3Hash
461
+ * @signature b.network.dns.dnssec.nsec3Hash(name, opts)
462
+ * @since 0.12.49
463
+ * @status stable
464
+ * @related b.network.dns.dnssec.verifyDenial
465
+ *
466
+ * Compute the RFC 5155 §5 NSEC3 hash of a name — iterated SHA-1 over the
467
+ * canonical (lowercased, root-terminated) wire form with the zone's salt.
468
+ * The result is the unencoded hash; the NSEC3 owner label is its
469
+ * base32hex encoding. SHA-1 is the only hash IANA registers for NSEC3,
470
+ * so this is a wire-protocol constant, not a cryptographic default.
471
+ *
472
+ * @opts
473
+ * {
474
+ * salt: Buffer, // zone NSEC3 salt (may be empty)
475
+ * iterations: number, // additional hash iterations (>= 0)
476
+ * }
477
+ *
478
+ * @example
479
+ * var h = b.network.dns.dnssec.nsec3Hash("a.example.com", { salt: salt, iterations: 0 });
480
+ */
481
+ function nsec3Hash(name, opts) {
482
+ validateOpts.requireObject(opts, "dnssec.nsec3Hash", DnssecError);
483
+ validateOpts(opts, ["salt", "iterations"], "dnssec.nsec3Hash");
484
+ var salt = _bytes(opts.salt, "salt");
485
+ var iters = opts.iterations;
486
+ if (typeof iters !== "number" || !isFinite(iters) || iters < 0 || Math.floor(iters) !== iters) {
487
+ throw new DnssecError("dnssec/bad-iterations", "dnssec.nsec3Hash: iterations must be a non-negative integer");
488
+ }
489
+ return _nsec3HashWire(_canonicalName(name), salt, iters);
490
+ }
491
+
492
+ /**
493
+ * @primitive b.network.dns.dnssec.verifyDenial
494
+ * @signature b.network.dns.dnssec.verifyDenial(opts)
495
+ * @since 0.12.49
496
+ * @status stable
497
+ * @compliance soc2
498
+ * @related b.network.dns.dnssec.verifyRrset, b.network.dns.dnssec.nsec3Hash
499
+ *
500
+ * Prove that a name does not exist (NXDOMAIN) or that a name has no
501
+ * records of a given type (NODATA) from the signed NSEC (RFC 4034 §4) or
502
+ * NSEC3 (RFC 5155) records in a response's Authority section. This is the
503
+ * other half of "verify the answer yourself": <code>verifyRrset</code>
504
+ * proves a positive answer, <code>verifyDenial</code> proves a negative.
505
+ *
506
+ * The records MUST already be verified with <code>verifyRrset</code> —
507
+ * this checks the denial RELATION (closest-encloser, covering ranges,
508
+ * type-bitmap absence), not the signatures. For NSEC3, the iterated-hash
509
+ * count is capped (<code>opts.maxIterations</code>, default 500) to bound
510
+ * the SHA-1 work an attacker can force. An NXDOMAIN proof that relies on
511
+ * an Opt-Out NSEC3 (RFC 5155 §6) is refused unless
512
+ * <code>opts.allowOptOut</code> — opt-out only proves "no signed records",
513
+ * not non-existence.
514
+ *
515
+ * @opts
516
+ * {
517
+ * qname: string, // the queried name
518
+ * qtype: string|number, // queried type (required for proof "nodata")
519
+ * proof: string, // "nxdomain" | "nodata"
520
+ * zone: string, // the signer zone apex (a suffix of qname)
521
+ * nsec3?: [ { owner: string, rdata: Buffer } ], // NSEC3 records (owner = base32hex-label.zone)
522
+ * nsec?: [ { owner: string, rdata: Buffer } ], // NSEC records
523
+ * maxIterations?: number, // NSEC3 iteration cap (default 500)
524
+ * allowOptOut?: boolean, // accept an Opt-Out NXDOMAIN proof (default false)
525
+ * }
526
+ *
527
+ * @example
528
+ * b.network.dns.dnssec.verifyDenial({
529
+ * qname: "nope.example.com", proof: "nxdomain", zone: "example.com", nsec3: records,
530
+ * });
531
+ */
532
+ function verifyDenial(opts) {
533
+ validateOpts.requireObject(opts, "dnssec.verifyDenial", DnssecError);
534
+ validateOpts(opts, ["qname", "qtype", "proof", "zone", "nsec3", "nsec", "maxIterations", "allowOptOut"], "dnssec.verifyDenial");
535
+ if (typeof opts.qname !== "string" || opts.qname === "") throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: opts.qname is required");
536
+ if (typeof opts.zone !== "string" || opts.zone === "") throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: opts.zone is required");
537
+ if (opts.proof !== "nxdomain" && opts.proof !== "nodata") throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: opts.proof must be 'nxdomain' or 'nodata'");
538
+ var zl = _nameLabels(opts.zone), ql = _nameLabels(opts.qname);
539
+ if (zl.length > ql.length || zl.join(".").toLowerCase() !== ql.slice(ql.length - zl.length).join(".").toLowerCase()) {
540
+ throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: opts.zone must be a suffix of opts.qname");
541
+ }
542
+ var qtypeNum;
543
+ if (opts.proof === "nodata") {
544
+ if (opts.qtype === undefined || opts.qtype === null) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: opts.qtype is required for a nodata proof");
545
+ qtypeNum = _typeNumber(opts.qtype);
546
+ } else if (opts.qtype !== undefined && opts.qtype !== null) {
547
+ qtypeNum = _typeNumber(opts.qtype);
548
+ }
549
+
550
+ var hasNsec3 = Array.isArray(opts.nsec3) && opts.nsec3.length > 0;
551
+ var hasNsec = Array.isArray(opts.nsec) && opts.nsec.length > 0;
552
+ if (hasNsec3 === hasNsec) {
553
+ throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: supply exactly one of opts.nsec3 or opts.nsec");
554
+ }
555
+ return hasNsec3 ? _verifyNsec3Denial(opts, qtypeNum) : _verifyNsecDenial(opts, qtypeNum);
556
+ }
557
+
558
+ function _verifyNsec3Denial(opts, qtypeNum) {
559
+ var maxIter = typeof opts.maxIterations === "number" ? opts.maxIterations : DEFAULT_MAX_NSEC3_ITERATIONS;
560
+ if (typeof maxIter !== "number" || !isFinite(maxIter) || maxIter < 0) throw new DnssecError("dnssec/bad-arg", "dnssec.verifyDenial: maxIterations must be a non-negative number");
561
+
562
+ // Parse + sanity-check every NSEC3 record; the chain shares one salt /
563
+ // iteration / hash-algorithm tuple.
564
+ var recs = opts.nsec3.map(function (r, i) {
565
+ if (!r || typeof r.owner !== "string") throw new DnssecError("dnssec/bad-nsec3", "dnssec.verifyDenial: nsec3[" + i + "].owner must be a string");
566
+ var rd = _bytes(r.rdata, "nsec3[" + i + "].rdata");
567
+ var p = _parseNsec3Rdata(rd);
568
+ if (p.hashAlg !== NSEC3_HASH_SHA1) throw new DnssecError("dnssec/unsupported-nsec3-hash", "dnssec.verifyDenial: NSEC3 hash algorithm " + p.hashAlg + " is not supported (only SHA-1 / 1 is defined)");
569
+ if (p.iterations > maxIter) throw new DnssecError("dnssec/nsec3-iterations-excessive", "dnssec.verifyDenial: NSEC3 iterations " + p.iterations + " exceed the cap " + maxIter);
570
+ var firstLabel = _nameLabels(r.owner)[0];
571
+ if (!firstLabel) throw new DnssecError("dnssec/bad-nsec3", "dnssec.verifyDenial: nsec3[" + i + "].owner has no hash label");
572
+ return { ownerHash: _base32hexDecode(firstLabel, "nsec3[" + i + "].owner"), p: p };
573
+ });
574
+ var salt = recs[0].p.salt, iterations = recs[0].p.iterations;
575
+ for (var s = 1; s < recs.length; s++) {
576
+ if (recs[s].p.iterations !== iterations || Buffer.compare(recs[s].p.salt, salt) !== 0) {
577
+ throw new DnssecError("dnssec/nsec3-param-mismatch", "dnssec.verifyDenial: NSEC3 records disagree on salt / iterations");
578
+ }
579
+ }
580
+
581
+ function hashOf(name) { return _nsec3HashWire(_canonicalName(name), salt, iterations); }
582
+ function findMatch(name) {
583
+ var h = hashOf(name);
584
+ for (var i = 0; i < recs.length; i++) if (Buffer.compare(recs[i].ownerHash, h) === 0) return recs[i];
585
+ return null;
586
+ }
587
+ function findCover(name) {
588
+ var h = hashOf(name);
589
+ for (var i = 0; i < recs.length; i++) {
590
+ var owner = recs[i].ownerHash, next = recs[i].p.nextHashed;
591
+ var oc = Buffer.compare(owner, next);
592
+ var covered = oc < 0
593
+ ? (Buffer.compare(owner, h) < 0 && Buffer.compare(h, next) < 0)
594
+ : (Buffer.compare(owner, h) < 0 || Buffer.compare(h, next) < 0); // last NSEC3 wraps past the apex
595
+ if (covered) return recs[i];
596
+ }
597
+ return null;
598
+ }
599
+
600
+ if (opts.proof === "nodata") {
601
+ // RFC 5155 §8.5 — a matching NSEC3 with the type (and CNAME) absent.
602
+ var m = findMatch(opts.qname);
603
+ if (m) {
604
+ if (qtypeNum === TYPE_DS) {
605
+ if (m.p.types.has(TYPE_DS)) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: DS is present in the matching NSEC3 bitmap");
606
+ return { ok: true, proof: "nodata", mechanism: "nsec3", matched: true };
607
+ }
608
+ if (m.p.types.has(qtypeNum)) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: type " + qtypeNum + " is present in the matching NSEC3 bitmap");
609
+ if (m.p.types.has(TYPE_CNAME)) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: name is a CNAME (query should have been redirected)");
610
+ return { ok: true, proof: "nodata", mechanism: "nsec3", matched: true };
611
+ }
612
+ // RFC 5155 §8.6 — Opt-Out DS NODATA: a covering NSEC3 with Opt-Out set
613
+ // proves an insecure delegation has no DS.
614
+ if (qtypeNum === TYPE_DS) {
615
+ var ce = _nsec3ClosestEncloser(opts, recs, findMatch);
616
+ if (ce) {
617
+ var nc = _nextCloser(opts.qname, ce);
618
+ var cov = findCover(nc);
619
+ if (cov && (cov.p.flags & 1) === 1) return { ok: true, proof: "nodata", mechanism: "nsec3", matched: false, optOut: true };
620
+ }
621
+ }
622
+ // RFC 5155 §8.7 — wildcard NODATA: closest encloser proof + a matching
623
+ // wildcard NSEC3 with the type absent.
624
+ var ce2 = _nsec3ClosestEncloser(opts, recs, findMatch);
625
+ if (ce2) {
626
+ var wc = findMatch("*." + ce2);
627
+ if (wc && !wc.p.types.has(qtypeNum) && !wc.p.types.has(TYPE_CNAME)) {
628
+ return { ok: true, proof: "nodata", mechanism: "nsec3", matched: false, wildcard: true, closestEncloser: ce2 };
629
+ }
630
+ }
631
+ throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: no matching NSEC3 proves NODATA for the queried type");
632
+ }
633
+
634
+ // NXDOMAIN (RFC 5155 §8.4): matching closest encloser + covered next
635
+ // closer + covered wildcard. Opt-Out on the next-closer cover only
636
+ // proves "no signed records", so it is refused unless allowOptOut.
637
+ var ceName = _nsec3ClosestEncloser(opts, recs, findMatch);
638
+ if (!ceName) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: no NSEC3 matches any closest-encloser candidate");
639
+ var nextCloser = _nextCloser(opts.qname, ceName);
640
+ var ncCover = findCover(nextCloser);
641
+ if (!ncCover) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: the next-closer name is not covered by any NSEC3");
642
+ var optOut = (ncCover.p.flags & 1) === 1;
643
+ if (optOut && !opts.allowOptOut) throw new DnssecError("dnssec/denial-opt-out", "dnssec.verifyDenial: NXDOMAIN relies on an Opt-Out NSEC3 (set allowOptOut to accept it as 'no signed records')");
644
+ // The wildcard at the closest encloser must be proven NON-EXISTENT
645
+ // (covered). A MATCHING wildcard means it exists, so the name should
646
+ // have been wildcard-synthesised and NXDOMAIN would be a forgery.
647
+ if (!findCover("*." + ceName)) {
648
+ throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: the wildcard at the closest encloser is not covered (a matching wildcard would mean the name should have been synthesised)");
649
+ }
650
+ return { ok: true, proof: "nxdomain", mechanism: "nsec3", closestEncloser: ceName, optOut: optOut };
651
+ }
652
+
653
+ function _nsec3ClosestEncloser(opts, recs, findMatch) {
654
+ var cands = _closestEncloserCandidates(opts.qname, opts.zone);
655
+ for (var i = 0; i < cands.length; i++) if (findMatch(cands[i])) return cands[i];
656
+ return null;
657
+ }
658
+
659
+ function _verifyNsecDenial(opts, qtypeNum) {
660
+ var recs = opts.nsec.map(function (r, i) {
661
+ if (!r || typeof r.owner !== "string") throw new DnssecError("dnssec/bad-nsec", "dnssec.verifyDenial: nsec[" + i + "].owner must be a string");
662
+ return { owner: r.owner, p: _parseNsecRdata(_bytes(r.rdata, "nsec[" + i + "].rdata")) };
663
+ });
664
+ function findMatch(name) {
665
+ for (var i = 0; i < recs.length; i++) if (_canonicalNameCompare(recs[i].owner, name) === 0) return recs[i];
666
+ return null;
667
+ }
668
+ function findCover(name) {
669
+ for (var i = 0; i < recs.length; i++) {
670
+ var owner = recs[i].owner, next = recs[i].p.nextName;
671
+ var oc = _canonicalNameCompare(owner, next);
672
+ var afterOwner = _canonicalNameCompare(owner, name) < 0;
673
+ var covered = oc < 0
674
+ ? (afterOwner && _canonicalNameCompare(name, next) < 0)
675
+ : afterOwner; // last NSEC (next wraps to apex): any name after owner
676
+ if (covered) return recs[i];
677
+ }
678
+ return null;
679
+ }
680
+
681
+ if (opts.proof === "nodata") {
682
+ var m = findMatch(opts.qname);
683
+ if (!m) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: no NSEC matches the queried name");
684
+ if (m.p.types.has(qtypeNum)) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: type " + qtypeNum + " is present in the matching NSEC bitmap");
685
+ if (qtypeNum !== TYPE_CNAME && m.p.types.has(TYPE_CNAME)) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: name is a CNAME (query should have been redirected)");
686
+ return { ok: true, proof: "nodata", mechanism: "nsec", matched: true };
687
+ }
688
+
689
+ // NXDOMAIN (RFC 4035 §5.4): an NSEC covering qname AND an NSEC proving
690
+ // the source-of-synthesis wildcard does not exist.
691
+ var cover = findCover(opts.qname);
692
+ if (!cover) throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: no NSEC covers the queried name");
693
+ // The closest encloser is the longest common ancestor of qname and the
694
+ // covering NSEC's owner/next; the wildcard sits one label below it.
695
+ var ce = _nsecClosestEncloser(opts.qname, cover);
696
+ // The source-of-synthesis wildcard must be proven NON-EXISTENT
697
+ // (covered). A MATCHING wildcard owner means it exists, so the query
698
+ // should have been answered by wildcard expansion, not NXDOMAIN.
699
+ var wildcard = "*." + ce;
700
+ if (!findCover(wildcard)) {
701
+ throw new DnssecError("dnssec/denial-not-proven", "dnssec.verifyDenial: the wildcard at the closest encloser is not covered (a matching wildcard would mean the name should have been synthesised)");
702
+ }
703
+ return { ok: true, proof: "nxdomain", mechanism: "nsec", closestEncloser: ce };
704
+ }
705
+
706
+ // The closest encloser for an NSEC NXDOMAIN proof is the longest name
707
+ // that is a suffix of qname and an ancestor of both the covering NSEC's
708
+ // owner and its next name (RFC 4035 §5.3.4 / §5.4).
709
+ function _nsecClosestEncloser(qname, cover) {
710
+ var ql = _nameLabels(qname);
711
+ var a = _commonSuffixLen(qname, cover.owner);
712
+ var b = _commonSuffixLen(qname, cover.p.nextName);
713
+ var ceLen = Math.max(a, b);
714
+ return ql.slice(ql.length - ceLen).join(".") + ".";
715
+ }
716
+
717
+ function _commonSuffixLen(a, b) {
718
+ var la = _nameLabels(a).reverse(), lb = _nameLabels(b).reverse();
719
+ var n = 0, min = Math.min(la.length, lb.length);
720
+ while (n < min && la[n].toLowerCase() === lb[n].toLowerCase()) n++;
721
+ return n;
722
+ }
723
+
322
724
  module.exports = {
323
- verifyRrset: verifyRrset,
324
- verifyDs: verifyDs,
325
- keyTag: keyTag,
326
- ALGORITHMS: ALGS,
327
- DnssecError: DnssecError,
725
+ verifyRrset: verifyRrset,
726
+ verifyDs: verifyDs,
727
+ verifyDenial: verifyDenial,
728
+ nsec3Hash: nsec3Hash,
729
+ keyTag: keyTag,
730
+ ALGORITHMS: ALGS,
731
+ DnssecError: DnssecError,
328
732
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.48",
3
+ "version": "0.12.49",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.12.49",
4
+ "date": "2026-05-25",
5
+ "headline": "`b.network.dns.dnssec.verifyDenial` — NSEC / NSEC3 denial-of-existence",
6
+ "summary": "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.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.network.dns.dnssec.verifyDenial(opts)`",
13
+ "body": "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."
14
+ },
15
+ {
16
+ "title": "`b.network.dns.dnssec.nsec3Hash(name, opts)`",
17
+ "body": "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."
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "heading": "Changed",
23
+ "items": [
24
+ {
25
+ "title": "`verifyRrset` now accepts NSEC and NSEC3 RRsets",
26
+ "body": "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."
27
+ }
28
+ ]
29
+ }
30
+ ]
31
+ }
@@ -6263,6 +6263,20 @@ var KNOWN_ANTIPATTERNS = [
6263
6263
  ],
6264
6264
  reason: "CVE-2026-22817 — every JWT verifier that resolves a JWK BY ATTACKER-CONTROLLED HEADER (kid / x5t) must cross-check the declared alg against the JWK's kty (and crv for EC) BEFORE handing the key to node:crypto.verify. Imports that skip the check are exactly the confused-deputy shape (RS256→HS256 family). The shared helper `jwtExternal._assertAlgKtyMatch(alg, jwk)` is the single point of enforcement; new code routes through it. Allowlist entries are sign-side / pinned-cert paths where the JWK is not attacker-supplied, or (did.js) where a kty/crv allowlist stands in for alg/kty because the format carries no verification alg.",
6265
6265
  },
6266
+ {
6267
+ // DNSSEC denial-of-existence: a wildcard at the closest encloser in
6268
+ // an NXDOMAIN (Name Error) proof must be proven NON-EXISTENT
6269
+ // (covered). Accepting a MATCHING wildcard owner as proof lets a
6270
+ // forged NXDOMAIN suppress data that wildcard expansion should have
6271
+ // synthesised (RFC 4035 §5.4, RFC 5155 §8.4). The bug shape is a
6272
+ // boolean gate that treats "covered OR matched" as acceptable:
6273
+ // `!findCover(x) && !findMatch(x)`. The fix requires cover alone.
6274
+ id: "nsec-wildcard-cover-or-match-accepted",
6275
+ primitive: "wildcard non-existence in an NXDOMAIN proof requires findCover() alone — never `!findCover(x) && !findMatch(x)`",
6276
+ regex: /!\s*findCover\s*\([^)]*\)\s*&&\s*!\s*findMatch\s*\(/,
6277
+ allowlist: [],
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
+ },
6266
6280
  {
6267
6281
  // CVE-2026-23552 — cross-realm JWT acceptance via non-CT iss
6268
6282
  // compare. `payload.iss !== expectedIssuer` (or claims.iss / token.iss)
@@ -113,11 +113,174 @@ function testVerifyDs() {
113
113
  check("verifyDs: key-tag mismatch refused", code(function () { b.network.dns.dnssec.verifyDs({ ownerName: "cloudflare.com", dnskeyRdata: cf.ksk.rdata, ds: Object.assign({}, ds, { keyTag: (tag + 1) & 0xffff }) }); }) === "dnssec/keytag-mismatch");
114
114
  }
115
115
 
116
+ // --- Denial of existence (NSEC / NSEC3) ---
117
+
118
+ var B32H = "0123456789ABCDEFGHIJKLMNOPQRSTUV";
119
+ function b32hDecode(s) {
120
+ s = s.toUpperCase();
121
+ var bits = 0, val = 0, out = [];
122
+ for (var i = 0; i < s.length; i++) { val = (val << 5) | B32H.indexOf(s[i]); bits += 5; if (bits >= 8) { bits -= 8; out.push((val >> bits) & 0xff); } }
123
+ return Buffer.from(out);
124
+ }
125
+ var TY = { A: 1, NS: 2, CNAME: 5, SOA: 6, MX: 15, TXT: 16, AAAA: 28, RRSIG: 46, DNSKEY: 48, NSEC3PARAM: 51, CAA: 257, SRV: 33, DS: 43 };
126
+ function encodeBitmap(names) {
127
+ if (!names.length) return Buffer.alloc(0);
128
+ var byWin = {};
129
+ names.forEach(function (nm) { var t = TY[nm]; (byWin[t >> 8] = byWin[t >> 8] || {})[t & 0xff] = 1; });
130
+ var parts = [];
131
+ Object.keys(byWin).map(Number).sort(function (a, c) { return a - c; }).forEach(function (w) {
132
+ var bitsSet = Object.keys(byWin[w]).map(Number);
133
+ var len = (Math.max.apply(null, bitsSet) >> 3) + 1, bm = Buffer.alloc(len);
134
+ bitsSet.forEach(function (bit) { bm[bit >> 3] |= 0x80 >> (bit & 7); });
135
+ parts.push(Buffer.from([w, len]), bm);
136
+ });
137
+ return Buffer.concat(parts);
138
+ }
139
+ function nsec3Rdata(opts) {
140
+ var salt = opts.salt || Buffer.alloc(0);
141
+ var next = Buffer.isBuffer(opts.next) ? opts.next : b32hDecode(opts.next);
142
+ return Buffer.concat([
143
+ Buffer.from([opts.hashAlg === undefined ? 1 : opts.hashAlg, opts.flags || 0, (opts.iterations >> 8) & 0xff, opts.iterations & 0xff, salt.length]),
144
+ salt, Buffer.from([next.length]), next, encodeBitmap(opts.types || []),
145
+ ]);
146
+ }
147
+ function nsecRdata(nextName, types) {
148
+ var labels = nextName.replace(/\.$/, "").split(".");
149
+ var parts = [];
150
+ labels.forEach(function (l) { var bb = Buffer.from(l, "ascii"); parts.push(Buffer.from([bb.length]), bb); });
151
+ parts.push(Buffer.from([0]));
152
+ return Buffer.concat(parts.concat([encodeBitmap(types)]));
153
+ }
154
+ function bufInc(buf, delta) { var b2 = Buffer.from(buf); b2[b2.length - 1] = (b2[b2.length - 1] + delta) & 0xff; return b2; }
155
+
156
+ // Real `iana.org` NXDOMAIN proof (NSEC3, SHA-1, 0 iterations, empty salt),
157
+ // captured via Cloudflare DoH for nonexistent-blamejs-test-xyz.iana.org.
158
+ var IANA_NSEC3 = [
159
+ { owner: "uqk2hjod270o42j2v1hoi7qtr945lhmb.iana.org", next: "VAVBTBDJ8O7H3CJCP1HL1CDPRTFQP46L", types: [] },
160
+ { owner: "mvnqhoigoa305s1i78hp6cdv5n7lcutc.iana.org", next: "NGJOKE6KAKN5BC83M0IAPQVRBAJKQI3M", types: ["A", "NS", "SOA", "MX", "TXT", "AAAA", "RRSIG", "DNSKEY", "NSEC3PARAM", "CAA"] },
161
+ { owner: "0d5cbi611aogl6kk8jjsopfic6dcb42t.iana.org", next: "26CS5JG5RASD1SS5VNTJ9PSC7FDVQIEO", types: ["CNAME", "RRSIG"] },
162
+ ];
163
+ function ianaRecords() {
164
+ return IANA_NSEC3.map(function (r) { return { owner: r.owner, rdata: nsec3Rdata({ iterations: 0, next: r.next, types: r.types }) }; });
165
+ }
166
+
167
+ function testNsec3Real() {
168
+ // The NSEC3 hash of the apex equals the real apex owner label byte-exact.
169
+ var h = b.network.dns.dnssec.nsec3Hash("iana.org", { salt: Buffer.alloc(0), iterations: 0 });
170
+ check("nsec3Hash: matches the real iana.org apex owner label", Buffer.compare(h, b32hDecode("MVNQHOIGOA305S1I78HP6CDV5N7LCUTC")) === 0);
171
+
172
+ var out = b.network.dns.dnssec.verifyDenial({ qname: "nonexistent-blamejs-test-xyz.iana.org", proof: "nxdomain", zone: "iana.org", nsec3: ianaRecords() });
173
+ check("verifyDenial: real iana.org NXDOMAIN proven (NSEC3)", out.ok && out.proof === "nxdomain" && out.mechanism === "nsec3" && out.closestEncloser === "iana.org." && out.optOut === false);
174
+
175
+ // Apex NODATA: the apex NSEC3 matches iana.org; a type absent from its
176
+ // bitmap is proven NODATA, a type present is refused.
177
+ var nodata = b.network.dns.dnssec.verifyDenial({ qname: "iana.org", qtype: "SRV", proof: "nodata", zone: "iana.org", nsec3: ianaRecords() });
178
+ check("verifyDenial: real iana.org NODATA for absent type proven", nodata.ok && nodata.proof === "nodata" && nodata.matched === true);
179
+
180
+ function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
181
+ check("verifyDenial: NODATA refused when type IS present", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "iana.org", qtype: "A", proof: "nodata", zone: "iana.org", nsec3: ianaRecords() }); }) === "dnssec/denial-not-proven");
182
+ // Removing the next-closer cover breaks the NXDOMAIN proof.
183
+ check("verifyDenial: NXDOMAIN refused without a covering NSEC3", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "nonexistent-blamejs-test-xyz.iana.org", proof: "nxdomain", zone: "iana.org", nsec3: [ianaRecords()[1]] }); }) === "dnssec/denial-not-proven");
184
+ }
185
+
186
+ function testNsec3Caps() {
187
+ function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
188
+ // Iterations beyond the cap are refused (iterated-SHA-1 DoS bound).
189
+ var heavy = [{ owner: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.x", rdata: nsec3Rdata({ iterations: 9999, next: b32hDecode("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") }) }];
190
+ check("verifyDenial: excessive NSEC3 iterations refused", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "q.x", proof: "nxdomain", zone: "x", nsec3: heavy }); }) === "dnssec/nsec3-iterations-excessive");
191
+ // Unsupported hash algorithm refused.
192
+ var badHash = [{ owner: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.x", rdata: nsec3Rdata({ hashAlg: 2, iterations: 0, next: b32hDecode("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") }) }];
193
+ check("verifyDenial: unsupported NSEC3 hash algorithm refused", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "q.x", proof: "nxdomain", zone: "x", nsec3: badHash }); }) === "dnssec/unsupported-nsec3-hash");
194
+ }
195
+
196
+ function testNsec3OptOut() {
197
+ // Construct an Opt-Out NXDOMAIN: matching apex + opt-out next-closer
198
+ // cover + covered wildcard, using real hashes of chosen names.
199
+ var salt = Buffer.alloc(0);
200
+ function H(n) { return b.network.dns.dnssec.nsec3Hash(n, { salt: salt, iterations: 0 }); }
201
+ var hT = H("test"), hX = H("x.test"), hW = H("*.test");
202
+ function b32hEncode(buf) {
203
+ var bits = 0, val = 0, out = "";
204
+ for (var i = 0; i < buf.length; i++) { val = (val << 8) | buf[i]; bits += 8; while (bits >= 5) { bits -= 5; out += B32H[(val >> bits) & 31]; } }
205
+ if (bits > 0) out += B32H[(val << (5 - bits)) & 31];
206
+ return out;
207
+ }
208
+ var recs = [
209
+ { owner: b32hEncode(hT) + ".test", rdata: nsec3Rdata({ flags: 0, iterations: 0, next: bufInc(hT, 1), types: ["NS", "SOA"] }) },
210
+ { owner: b32hEncode(bufInc(hX, -1)) + ".test", rdata: nsec3Rdata({ flags: 1, iterations: 0, next: bufInc(hX, 1) }) },
211
+ { owner: b32hEncode(bufInc(hW, -1)) + ".test", rdata: nsec3Rdata({ flags: 0, iterations: 0, next: bufInc(hW, 1) }) },
212
+ ];
213
+ function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
214
+ check("verifyDenial: Opt-Out NXDOMAIN refused by default", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "x.test", proof: "nxdomain", zone: "test", nsec3: recs }); }) === "dnssec/denial-opt-out");
215
+ var out = b.network.dns.dnssec.verifyDenial({ qname: "x.test", proof: "nxdomain", zone: "test", nsec3: recs, allowOptOut: true });
216
+ check("verifyDenial: Opt-Out NXDOMAIN accepted with allowOptOut", out.ok && out.optOut === true);
217
+ }
218
+
219
+ function testWildcardMatchRejected() {
220
+ // NXDOMAIN must NOT be accepted when the wildcard at the closest
221
+ // encloser EXISTS (matches). A forged NXDOMAIN could otherwise suppress
222
+ // data that wildcard expansion should have synthesised.
223
+ function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
224
+ var salt = Buffer.alloc(0);
225
+ function H(n) { return b.network.dns.dnssec.nsec3Hash(n, { salt: salt, iterations: 0 }); }
226
+ function enc(buf) {
227
+ var bits = 0, val = 0, out = "";
228
+ for (var i = 0; i < buf.length; i++) { val = (val << 8) | buf[i]; bits += 8; while (bits >= 5) { bits -= 5; out += B32H[(val >> bits) & 31]; } }
229
+ if (bits > 0) out += B32H[(val << (5 - bits)) & 31];
230
+ return out;
231
+ }
232
+ var hT = H("test"), hX = H("x.test"), hW = H("*.test");
233
+ var recs = [
234
+ { owner: enc(hT) + ".test", rdata: nsec3Rdata({ iterations: 0, next: bufInc(hT, 1), types: ["NS", "SOA"] }) },
235
+ { owner: enc(bufInc(hX, -1)) + ".test", rdata: nsec3Rdata({ iterations: 0, next: bufInc(hX, 1) }) },
236
+ { owner: enc(hW) + ".test", rdata: nsec3Rdata({ iterations: 0, next: bufInc(hW, 1), types: ["A"] }) }, // wildcard EXISTS (matches)
237
+ ];
238
+ check("verifyDenial: NSEC3 NXDOMAIN refused when wildcard matches (exists)", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "x.test", proof: "nxdomain", zone: "test", nsec3: recs }); }) === "dnssec/denial-not-proven");
239
+
240
+ // NSEC equivalent: the wildcard owner exists in the chain.
241
+ var nsec = [
242
+ { owner: "example.com", rdata: nsecRdata("*.example.com", ["A", "NS", "SOA", "RRSIG", "DNSKEY"]) },
243
+ { owner: "*.example.com", rdata: nsecRdata("a.example.com", ["A", "RRSIG"]) },
244
+ { owner: "a.example.com", rdata: nsecRdata("example.com", ["A", "RRSIG"]) },
245
+ ];
246
+ check("verifyDenial: NSEC NXDOMAIN refused when wildcard owner exists", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "b.example.com", proof: "nxdomain", zone: "example.com", nsec: nsec }); }) === "dnssec/denial-not-proven");
247
+ }
248
+
249
+ function testNsec() {
250
+ // Synthetic NSEC zone: apex + one name, both with bitmaps.
251
+ var recs = [
252
+ { owner: "example.com", rdata: nsecRdata("a.example.com", ["A", "NS", "SOA", "RRSIG", "DNSKEY"]) },
253
+ { owner: "a.example.com", rdata: nsecRdata("example.com", ["A", "RRSIG"]) },
254
+ ];
255
+ var nx = b.network.dns.dnssec.verifyDenial({ qname: "b.example.com", proof: "nxdomain", zone: "example.com", nsec: recs });
256
+ check("verifyDenial: NSEC NXDOMAIN proven (covering + wildcard)", nx.ok && nx.mechanism === "nsec" && nx.closestEncloser === "example.com.");
257
+
258
+ var nd = b.network.dns.dnssec.verifyDenial({ qname: "example.com", qtype: "MX", proof: "nodata", zone: "example.com", nsec: recs });
259
+ check("verifyDenial: NSEC NODATA proven for absent type", nd.ok && nd.matched === true);
260
+
261
+ function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
262
+ check("verifyDenial: NSEC NODATA refused when type present", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "example.com", qtype: "A", proof: "nodata", zone: "example.com", nsec: recs }); }) === "dnssec/denial-not-proven");
263
+ }
264
+
265
+ function testDenialArgs() {
266
+ function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
267
+ check("verifyDenial: bad proof value refused", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "x.iana.org", proof: "maybe", zone: "iana.org", nsec3: ianaRecords() }); }) === "dnssec/bad-arg");
268
+ check("verifyDenial: zone not a suffix of qname refused", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "x.example.org", proof: "nxdomain", zone: "iana.org", nsec3: ianaRecords() }); }) === "dnssec/bad-arg");
269
+ check("verifyDenial: both nsec and nsec3 supplied refused", code(function () { b.network.dns.dnssec.verifyDenial({ qname: "x.iana.org", proof: "nxdomain", zone: "iana.org", nsec3: ianaRecords(), nsec: [{ owner: "iana.org", rdata: nsecRdata("a.iana.org", []) }] }); }) === "dnssec/bad-arg");
270
+ 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
+ }
272
+
116
273
  async function run() {
117
274
  testSurface();
118
275
  testRealVectors();
119
276
  testRefusals();
120
277
  testVerifyDs();
278
+ testNsec3Real();
279
+ testNsec3Caps();
280
+ testNsec3OptOut();
281
+ testWildcardMatchRejected();
282
+ testNsec();
283
+ testDenialArgs();
121
284
  }
122
285
 
123
286
  module.exports = { run: run };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {