@blamejs/blamejs-shop 0.1.2 → 0.1.5

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.5 (2026-05-25) — **Tell operators how to turn each integration on.** Every third-party integration (Stripe card checkout, Apple/Google Pay, Sign in with Google) is off by default and only activates when you supply its credentials. This release documents exactly what to set for each — in the README, in a new .env.example, and in the admin console itself. A signed-in operator opens /admin/integrations to see, at a glance, which integrations are live and the precise environment variables (or one-time action) needed to enable the rest. Nothing is enabled without your keys. **Added:** *Admin integrations status page* — `/admin/integrations` lists each integration with a live Enabled / Not configured status and the exact variables or action to turn it on — Stripe keys, the payment-method-domain registration for wallets, the Google OAuth client + redirect URI. Read-only; secrets are never rendered. Linked from the admin landing. · *Operator setup docs* — A README "Optional integrations" section and a top-level `.env.example` enumerate every environment variable, what capability it unlocks, and the external setup (e.g. the Google OAuth redirect URI, the Stripe webhook path). Both note that Apple sign-in + PayPal are planned and that Shop Pay / "Sign in with Shop" isn't available to a self-hosted store.
12
+
13
+ - 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`.
14
+
11
15
  - 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.
12
16
 
13
17
  - 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.
package/README.md CHANGED
@@ -62,7 +62,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
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
 
@@ -145,6 +146,32 @@ curl -X POST https://your-shop.example.com/admin/products \
145
146
 
146
147
  See [`docs/deploy-cloudflare.md`](docs/deploy-cloudflare.md) for the full deploy recipe.
147
148
 
149
+ ## Optional integrations — what to set to enable each
150
+
151
+ Every third-party integration is **off by default** and lights up only when you
152
+ supply its credentials. Nothing here phones home or is enabled without your
153
+ keys; the storefront runs fully (browse, cart, accounts) with none of them. Set
154
+ the values as deployment secrets (`wrangler secret put …`) or environment
155
+ variables. A signed-in operator can see the live on/off status of each at
156
+ **`/admin/integrations`**. See [`.env.example`](.env.example) for the full list.
157
+
158
+ | Integration | What it enables | Set this | Notes |
159
+ |-------------|-----------------|----------|-------|
160
+ | **Admin console** | The bearer-token JSON API + the `/admin` browser console (sign-in, setup wizard, dashboard). | `ADMIN_API_KEY` (≥ 16 chars — use 32 random bytes) | Sign in at `/admin` by pasting the key. Without it the admin surface doesn't mount. |
161
+ | **Card checkout (Stripe)** | Checkout + the Payment Element on the pay page; refunds; subscription billing. | `STRIPE_API_KEY` (`sk_…`), `STRIPE_WEBHOOK_SECRET` (`whsec_…`), `STRIPE_PUBLISHABLE_KEY` (`pk_…`) | Point your Stripe webhook at `/api/webhooks/stripe`. Without these the shop stays browsable but checkout doesn't mount. |
162
+ | **Apple Pay & Google Pay** | One-tap wallet buttons (Express Checkout Element) on the pay page. | Stripe (above) **+** register each web domain: `POST /admin/payment-method-domains {"domain_name":"shop.example.com"}` | Stripe performs Apple merchant validation and hosts the association file — **no Apple Developer account needed**. Apex, `www`, and each subdomain register separately; a live-mode registration also covers sandbox. |
163
+ | **Sign in with Google** | A *Continue with Google* button on `/account/login` (OIDC). | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `SHOP_ORIGIN` (e.g. `https://shop.example.com`) | Create a Google Cloud **OAuth 2.0 Web** client; add `<SHOP_ORIGIN>/account/auth/google/callback` as an Authorized redirect URI; consent-screen scopes `openid email profile`. The button appears only when all three are set. |
164
+
165
+ **Planned / not available:**
166
+
167
+ - **Sign in with Apple** — the flow is wired in the framework, but it needs an
168
+ Apple Developer Program membership ($99/yr) and an ES256 client-secret minted
169
+ from your `.p8` key. Config-optional; shipping behind those credentials.
170
+ - **PayPal** — a separate adapter (Orders v2 + its own webhook); planned.
171
+ - **Shop Pay / "Sign in with Shop"** — **not available** to a self-hosted,
172
+ non-Shopify store: the credentials only issue from a Shopify Admin and payment
173
+ flows through Shopify Payments. There is no path to enable it here.
174
+
148
175
  ## Vendoring blamejs
149
176
 
150
177
  `blamejs.shop` vendors blamejs as a shallow git clone of the release tag
package/lib/admin.js CHANGED
@@ -1037,6 +1037,19 @@ function mount(router, deps) {
1037
1037
  _redirect(res, "/admin");
1038
1038
  });
1039
1039
 
1040
+ // Integrations status — what's live + what to set to enable the rest.
1041
+ // `deps.integrations` is the live on/off map computed at the entry
1042
+ // point from the environment (admin.js never reads process.env).
1043
+ router.get("/admin/integrations", async function (req, res) {
1044
+ if (!_htmlAuthed(req, expectedToken)) {
1045
+ return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
1046
+ }
1047
+ _sendHtml(res, 200, renderAdminIntegrations({
1048
+ shop_name: deps.shop_name,
1049
+ status: deps.integrations || {},
1050
+ }));
1051
+ });
1052
+
1040
1053
  if (config) {
1041
1054
  router.get("/admin/setup", async function (req, res) {
1042
1055
  if (!_htmlAuthed(req, expectedToken)) return _redirect(res, "/admin");
@@ -1365,6 +1378,7 @@ function renderAdminLanding(opts) {
1365
1378
  "<h2>Admin</h2>" +
1366
1379
  "<div class=\"nav-cards\">" +
1367
1380
  "<a class=\"nav-card\" href=\"/admin/setup\"><h3>Setup wizard</h3><p>Shop identity, currency, and contact details.</p></a>" +
1381
+ "<a class=\"nav-card\" href=\"/admin/integrations\"><h3>Integrations</h3><p>Payments, wallets, and sign-in — what's live and what to set.</p></a>" +
1368
1382
  "<a class=\"nav-card\" href=\"/admin/dashboard\"><h3>Dashboard</h3><p>Sales, revenue, and recent orders at a glance.</p></a>" +
1369
1383
  "</div>" +
1370
1384
  "<div class=\"actions-row\"><form method=\"post\" action=\"/admin/logout\"><button type=\"submit\" class=\"btn btn--ghost\">Sign out</button></form></div>" +
@@ -1401,11 +1415,64 @@ function renderAdminSetup(opts) {
1401
1415
  return _renderAdminShell(opts.shop_name, "Setup", body);
1402
1416
  }
1403
1417
 
1418
+ // Each integration is off until the operator supplies its credentials.
1419
+ // `opts.status` carries the live booleans (computed at the entry point
1420
+ // from the environment); this page shows what's on and exactly what to
1421
+ // set to turn the rest on. Read-only — secrets are never rendered.
1422
+ var INTEGRATIONS_CATALOG = [
1423
+ { key: "stripe", name: "Card checkout (Stripe)", enables: "Checkout, the Payment Element, refunds, and subscription billing.",
1424
+ set: "STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PUBLISHABLE_KEY (point the Stripe webhook at /api/webhooks/stripe)." },
1425
+ { key: "express_checkout", name: "Apple Pay & Google Pay", enables: "One-tap wallet buttons on the pay page.",
1426
+ set: "Configure Stripe (above), then register each domain: POST /admin/payment-method-domains {\"domain_name\":\"shop.example.com\"}. No Apple Developer account needed." },
1427
+ { key: "google_signin", name: "Sign in with Google", enables: "A “Continue with Google” button on the account login page.",
1428
+ set: "GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, SHOP_ORIGIN. Add <SHOP_ORIGIN>/account/auth/google/callback as a Google OAuth redirect URI." },
1429
+ ];
1430
+
1431
+ function renderAdminIntegrations(opts) {
1432
+ opts = opts || {};
1433
+ var status = opts.status || {};
1434
+ var rows = INTEGRATIONS_CATALOG.map(function (it) {
1435
+ // Three states: "enabled" (live), "action" (credentials present but a
1436
+ // one-time operator action — e.g. registering a domain with Stripe —
1437
+ // is still required before it's actually live), "off" (not configured).
1438
+ var st = status[it.key] || "off";
1439
+ var pill, detail;
1440
+ if (st === "enabled") {
1441
+ pill = "<span class=\"status-pill paid\">Enabled</span>";
1442
+ detail = "<span class=\"meta\">Live.</span>";
1443
+ } else if (st === "action") {
1444
+ pill = "<span class=\"status-pill pending\">Action needed</span>";
1445
+ detail = "<span class=\"meta\">" + _htmlEscape(it.set) + "</span>";
1446
+ } else {
1447
+ pill = "<span class=\"status-pill cancelled\">Not configured</span>";
1448
+ detail = "<span class=\"meta\">" + _htmlEscape(it.set) + "</span>";
1449
+ }
1450
+ return "<tr>" +
1451
+ "<td><strong>" + _htmlEscape(it.name) + "</strong><br><span class=\"meta\">" + _htmlEscape(it.enables) + "</span></td>" +
1452
+ "<td>" + pill + "</td>" +
1453
+ "<td>" + detail + "</td>" +
1454
+ "</tr>";
1455
+ }).join("");
1456
+ var body =
1457
+ "<section>" +
1458
+ "<h2>Integrations</h2>" +
1459
+ "<p class=\"meta\">Every integration is off until you supply its credentials — set them as deployment secrets, then redeploy. Nothing is enabled without your keys.</p>" +
1460
+ "<div class=\"panel\"><table>" +
1461
+ "<thead><tr><th>Integration</th><th>Status</th><th>To enable</th></tr></thead>" +
1462
+ "<tbody>" + rows + "</tbody>" +
1463
+ "</table></div>" +
1464
+ "<p class=\"meta\" style=\"margin-top:1.25rem;\">Sign in with Apple and PayPal are planned. “Sign in with Shop” / Shop Pay isn't available to a self-hosted store. See the README “Optional integrations” section for full setup steps.</p>" +
1465
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
1466
+ "</section>";
1467
+ return _renderAdminShell(opts.shop_name, "Integrations", body);
1468
+ }
1469
+
1404
1470
  module.exports = {
1405
1471
  mount: mount,
1406
1472
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
1407
1473
  renderDashboard: renderDashboard,
1408
- renderAdminLogin: renderAdminLogin,
1409
- renderAdminLanding: renderAdminLanding,
1410
- renderAdminSetup: renderAdminSetup,
1474
+ renderAdminLogin: renderAdminLogin,
1475
+ renderAdminLanding: renderAdminLanding,
1476
+ renderAdminSetup: renderAdminSetup,
1477
+ renderAdminIntegrations: renderAdminIntegrations,
1411
1478
  };
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/storefront.js CHANGED
@@ -2122,6 +2122,12 @@ var CHALLENGE_COOKIE_NAME = "shop_auth_chal";
2122
2122
  // SameSite=Strict so it's only ever sent to the pay route.
2123
2123
  var PAY_COOKIE_NAME = "shop_pay";
2124
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
+
2125
2131
  // Shape of a valid session id — mirrors cart.js's SESSION_ID_RE.
2126
2132
  var SID_SHAPE_RE = /^[A-Za-z0-9_-]{16,64}$/;
2127
2133
 
@@ -2199,11 +2205,13 @@ var ACCOUNT_LOGIN_PAGE =
2199
2205
  " <p class=\"eyebrow\">Sign in</p>\n" +
2200
2206
  " <h1 class=\"auth-card__title\">Welcome back</h1>\n" +
2201
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" +
2202
2209
  " <form id=\"login-form\" method=\"post\" class=\"form-stack auth-form\">\n" +
2203
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" +
2204
2211
  " <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Sign in with passkey</button></div>\n" +
2205
2212
  " <p id=\"login-message\" class=\"auth-form__message\"></p>\n" +
2206
2213
  " </form>\n" +
2214
+ " RAW_LOGIN_OAUTH\n" +
2207
2215
  " <p class=\"auth-card__alt\">New here? <a href=\"/account/register\">Create an account →</a></p>\n" +
2208
2216
  " </div>\n" +
2209
2217
  " <script>\n" +
@@ -2230,14 +2238,31 @@ var ACCOUNT_LOGIN_PAGE =
2230
2238
  " </script>\n" +
2231
2239
  "</section>\n";
2232
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
+
2233
2246
  function renderAccountLogin(opts) {
2234
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);
2235
2260
  return _wrap({
2236
2261
  title: "Sign in",
2237
2262
  shop_name: opts.shop_name || "blamejs.shop",
2238
2263
  cart_count: opts.cart_count,
2239
2264
  theme_css: opts.theme_css,
2240
- body: ACCOUNT_LOGIN_PAGE,
2265
+ body: body,
2241
2266
  });
2242
2267
  }
2243
2268
 
@@ -2907,7 +2932,13 @@ function mount(router, deps) {
2907
2932
 
2908
2933
  router.get("/account/login", async function (req, res) {
2909
2934
  var cartCount = await _cartCountForReq(req);
2910
- _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
+ }));
2911
2942
  });
2912
2943
 
2913
2944
  router.get("/account/register", async function (req, res) {
@@ -3188,6 +3219,87 @@ function mount(router, deps) {
3188
3219
  return res.end ? res.end() : res.send("");
3189
3220
  });
3190
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
+
3191
3303
  // Wishlist — saved products scoped to the logged-in customer.
3192
3304
  // Mounts when the wishlist primitive is wired.
3193
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.2",
3
+ "version": "0.1.5",
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": {