@blamejs/blamejs-shop 0.0.128 → 0.0.129

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.0.x
10
10
 
11
+ - v0.0.129 (2026-05-25) — **Recently viewed — a signed-in customer's browse history at /account/recently-viewed.** Signed-in customers now have a browse history. Opening a product page records the view against the customer's account, and `/account/recently-viewed` lists those products newest-first as a grid, with a one-tap Clear history control. Recording is best-effort and never blocks the product page; archived products drop out of the grid; the page is login-gated like the rest of the account area. **Added:** *Recently viewed account page* — `GET /account/recently-viewed` renders the customer's most-recently-opened products newest-first, reusing the standard product card (image, title, price, link to the PDP). A `POST /account/recently-viewed/clear` control wipes the history. Linked from the account dashboard. · *Server-side view recording* — A signed-in customer's product-page visit records the view against their account — no client script. Recording is drop-silent: a write failure never breaks the product page. The history de-dupes per product and is capped per customer.
12
+
11
13
  - v0.0.128 (2026-05-25) — **Collections — browse curated and smart product lists at /collections.** The storefront now has real collection pages. `/collections` lists the shop's active collections, and `/collections/:slug` shows a collection's products as a grid — resolving both operator-curated manual collections (hand-picked members) and smart collections (rule-matched against the catalog). The footer links to it from every page, so collections are a first-class browse entry point rather than a search query. Unknown or malformed collection slugs return 404, never a 500. **Added:** *Collection browse pages* — `GET /collections` renders the active collections as cards (title, description, hero); `GET /collections/:slug` renders the collection's product grid, reusing the standard product card (image, title, price, link to the PDP). Public pages — no sign-in. · *Manual + smart resolution* — Manual collections list their hand-picked members; smart collections evaluate their stored rules against the active catalog and apply the collection's sort strategy. The page resolves each product fresh, so archived products drop out of the grid. · *Footer entry point* — The footer's Shop column links to `/collections` on every storefront page (edge- and container-rendered alike), making collections a real browse path. A bad or unknown slug is a 404.
12
14
 
13
15
  - v0.0.127 (2026-05-24) — **Self-serve returns — customers request RMAs on their orders, operators approve and refund.** Signed-in customers can request a return against one of their own orders — pick the items and a reason at the order, then track status at /account/returns. Operators work the queue at /admin/returns: approve (with a refund amount), mark received, refund, or reject with a reason, following the pending → approved → received → refunded lifecycle. The customer request route loads the order and confirms it belongs to the signed-in customer before showing it, and builds the return lines from the order's own records, so a foreign or guessed order id returns 404 and a client can't return items it never bought. **Added:** *Customer return requests + status* — `/account/orders/:order_id/return` shows the order's items with a reason picker; `/account/returns` lists the customer's RMAs with status (pending / approved / received / refunded / rejected) and any rejection reason. The account dashboard links to it. Empty selection or a bad reason re-renders the form with the message. · *Operator return queue* — `GET /admin/returns?status=pending` lists the queue across all orders; `GET /admin/returns/:id` reads one; `POST /admin/returns/:id/approve` (with refund amount), `/received`, `/refund`, and `/reject` (with reason) walk the lifecycle. Bearer-token-gated; an illegal transition is a 409 and a bad id a 404, never a 500. · *`returns.listByStatus(status, opts)`* — Lists return authorizations across all orders by status, newest first, with the same opaque cursor as `listForCustomer`. Backs the operator queue.
package/README.md CHANGED
@@ -68,6 +68,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
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`. |
69
69
  | **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
70
70
  | **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
71
+ | **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
71
72
  | **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
72
73
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. |
73
74
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
@@ -92,6 +93,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
92
93
  - `migrations-d1/0026_customer_addresses.sql` — per-customer address book (default shipping/billing flags)
93
94
  - `migrations-d1/0023_returns.sql` — return authorizations + lines (RMA lifecycle FSM)
94
95
  - `migrations-d1/0043_collections.sql` — manual + smart product collections (members + rules + sort strategy)
96
+ - `migrations-d1/0050_recently_viewed.sql` — per-customer / per-session product browse history (dedup + per-subject cap)
95
97
 
96
98
  ### Demo seed
97
99
 
package/lib/storefront.js CHANGED
@@ -1199,6 +1199,40 @@ function renderCollection(opts) {
1199
1199
  });
1200
1200
  }
1201
1201
 
1202
+ // Account "Recently viewed" page — a newest-first grid of products the
1203
+ // signed-in customer has opened, reusing the standard product card.
1204
+ // `opts.products` is a resolved [{ slug, title, price, image_url,
1205
+ // image_alt }] list (archived products are dropped before render, so
1206
+ // the grid is orphan-tolerant). A "Clear history" control renders only
1207
+ // when the list is non-empty.
1208
+ function renderRecentlyViewed(opts) {
1209
+ var products = opts.products || [];
1210
+ var cards = products.map(function (p) { return _buildProductCard(p); }).join("");
1211
+ var grid = cards
1212
+ ? "<div class=\"catalog-grid recently-viewed-grid\">" + cards + "</div>"
1213
+ : "<p class=\"recently-viewed-empty\">You haven't viewed any products yet. As you browse the shop, the products you open show up here.</p>";
1214
+ var clear = cards
1215
+ ? "<form class=\"recently-viewed__clear\" method=\"post\" action=\"/account/recently-viewed/clear\">" +
1216
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Clear history</button></form>"
1217
+ : "";
1218
+ var body =
1219
+ "<section class=\"account-recently-viewed\">" +
1220
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1221
+ "<li><a href=\"/account\">Account</a></li>" +
1222
+ "<li aria-current=\"page\">Recently viewed</li>" +
1223
+ "</ol></nav>" +
1224
+ "<header class=\"account-recently-viewed__head\">" +
1225
+ "<h1 class=\"account-recently-viewed__title\">Recently viewed</h1>" +
1226
+ clear +
1227
+ "</header>" +
1228
+ grid +
1229
+ "</section>";
1230
+ return _wrap({
1231
+ title: "Recently viewed", shop_name: opts.shop_name || "blamejs.shop",
1232
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
1233
+ });
1234
+ }
1235
+
1202
1236
  var RETURN_REASONS = [
1203
1237
  ["defective", "Defective / doesn't work"],
1204
1238
  ["wrong-item", "Wrong item received"],
@@ -2244,6 +2278,7 @@ var ACCOUNT_DASH_PAGE =
2244
2278
  " <div class=\"account-dash__actions\">\n" +
2245
2279
  " <a class=\"btn-secondary\" href=\"/account/wishlist\">Wishlist</a>\n" +
2246
2280
  " <a class=\"btn-secondary\" href=\"/account/saved\">Saved for later</a>\n" +
2281
+ " <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
2247
2282
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
2248
2283
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
2249
2284
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
@@ -2380,6 +2415,45 @@ function mount(router, deps) {
2380
2415
  return lines.length;
2381
2416
  }
2382
2417
 
2418
+ // The signed-in customer's sealed-cookie envelope, or null. Shared by
2419
+ // the PDP view recorder (mounted outside the `if (deps.customers)`
2420
+ // block) and the account routes inside it, so there's one auth-cookie
2421
+ // reader rather than a copy per call site. A missing / malformed /
2422
+ // expired cookie returns null — never throws.
2423
+ function _currentCustomerEnv(req) {
2424
+ var raw = _readCookie(req, AUTH_COOKIE_NAME);
2425
+ if (!raw) return null;
2426
+ var env;
2427
+ try { env = JSON.parse(_b().vault.unseal(raw)); } catch (_e) { return null; }
2428
+ if (!env || !env.customer_id || !env.exp || env.exp < Date.now()) return null;
2429
+ return env;
2430
+ }
2431
+
2432
+ // Resolve a product id into the { slug, title, price, image_url,
2433
+ // image_alt } shape `_buildProductCard` expects. Returns null for an
2434
+ // archived / missing product so it drops out of any grid (collections,
2435
+ // recently-viewed). Shared so the decoration rule lives in one place.
2436
+ var _cardAssetPrefix = deps.asset_prefix || "/assets/";
2437
+ async function _decorateProductCard(pid) {
2438
+ var product = await deps.catalog.products.get(pid);
2439
+ if (!product || product.status !== "active") return null;
2440
+ var priceStr = "—";
2441
+ var variants = await deps.catalog.variants.listForProduct(pid);
2442
+ if (variants.length) {
2443
+ var pr = await deps.catalog.prices.current(variants[0].id, "USD");
2444
+ if (pr) priceStr = pricing.format(pr.amount_minor, pr.currency);
2445
+ }
2446
+ var media = await deps.catalog.media.listForProduct(pid);
2447
+ var hero = media.length ? media[0] : null;
2448
+ return {
2449
+ slug: product.slug,
2450
+ title: product.title,
2451
+ price: priceStr,
2452
+ image_url: hero ? (_cardAssetPrefix + hero.r2_key) : null,
2453
+ image_alt: hero ? (hero.alt_text || product.title) : null,
2454
+ };
2455
+ }
2456
+
2383
2457
  // Resolve the cart for this request — read session_id from the
2384
2458
  // sealed cookie, create one (and the cart) if absent. Returns
2385
2459
  // the cart row OR null when the cart was just created (caller can
@@ -2520,6 +2594,19 @@ function mount(router, deps) {
2520
2594
  try { wishlistCount = await deps.wishlist.countForProduct(product.id); }
2521
2595
  catch (_e) { wishlistCount = 0; }
2522
2596
  }
2597
+ // Log the view for a signed-in customer so it surfaces on their
2598
+ // "Recently viewed" account page. Drop-silent — a recording failure
2599
+ // (table not migrated, write contention) must never break the PDP
2600
+ // render. Guests aren't recorded here: their PDP is edge-cached with
2601
+ // no per-request container hop, so session-scoped guest history is a
2602
+ // separate opt-in (a client beacon) the storefront doesn't ship yet.
2603
+ if (deps.recentlyViewed && deps.customers) {
2604
+ var rvEnv = _currentCustomerEnv(req);
2605
+ if (rvEnv) {
2606
+ try { await deps.recentlyViewed.recordView({ customer_id: rvEnv.customer_id, product_id: product.id }); }
2607
+ catch (_e) { /* drop-silent — recently-viewed is supplementary to the buy path */ }
2608
+ }
2609
+ }
2523
2610
  var html = renderProduct({
2524
2611
  product: product,
2525
2612
  variants: variants,
@@ -2539,36 +2626,11 @@ function mount(router, deps) {
2539
2626
  // Collections — operator-curated + smart product lists. Public browse
2540
2627
  // pages; mounted when the collections primitive is wired.
2541
2628
  if (deps.collections) {
2542
- var _collAssetPrefix = deps.asset_prefix || "/assets/";
2543
-
2544
- // Decorate a product id into the { slug, title, price, image_url }
2545
- // shape _buildProductCard expects. Returns null for an archived /
2546
- // missing product so it drops out of the grid.
2547
- async function _decorateCollectionProduct(pid) {
2548
- var product = await deps.catalog.products.get(pid);
2549
- if (!product || product.status !== "active") return null;
2550
- var priceStr = "—";
2551
- var variants = await deps.catalog.variants.listForProduct(pid);
2552
- if (variants.length) {
2553
- var pr = await deps.catalog.prices.current(variants[0].id, "USD");
2554
- if (pr) priceStr = pricing.format(pr.amount_minor, pr.currency);
2555
- }
2556
- var media = await deps.catalog.media.listForProduct(pid);
2557
- var hero = media.length ? media[0] : null;
2558
- return {
2559
- slug: product.slug,
2560
- title: product.title,
2561
- price: priceStr,
2562
- image_url: hero ? (_collAssetPrefix + hero.r2_key) : null,
2563
- image_alt: hero ? (hero.alt_text || product.title) : null,
2564
- };
2565
- }
2566
-
2567
2629
  router.get("/collections", async function (req, res) {
2568
2630
  var cols = await deps.collections.list({ active_only: true });
2569
2631
  var cartCount = await _cartCountForReq(req);
2570
2632
  _send(res, 200, renderCollectionList({
2571
- collections: cols, shop_name: shopName, cart_count: cartCount, asset_prefix: _collAssetPrefix,
2633
+ collections: cols, shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
2572
2634
  }));
2573
2635
  });
2574
2636
 
@@ -2589,7 +2651,7 @@ function mount(router, deps) {
2589
2651
  var products = [];
2590
2652
  for (var i = 0; i < result.rows.length; i += 1) {
2591
2653
  var pid = result.rows[i].product_id || result.rows[i].id;
2592
- var card = await _decorateCollectionProduct(pid);
2654
+ var card = await _decorateProductCard(pid);
2593
2655
  if (card) products.push(card);
2594
2656
  }
2595
2657
  var cartCount = await _cartCountForReq(req);
@@ -2820,12 +2882,7 @@ function mount(router, deps) {
2820
2882
  }
2821
2883
 
2822
2884
  function _currentCustomer(req) {
2823
- var raw = _readCookie(req, AUTH_COOKIE_NAME);
2824
- if (!raw) return null;
2825
- var env = _unsealEnvelope(raw);
2826
- if (!env || !env.customer_id || !env.exp) return null;
2827
- if (env.exp < Date.now()) return null;
2828
- return env;
2885
+ return _currentCustomerEnv(req);
2829
2886
  }
2830
2887
 
2831
2888
  function _serviceUnavailable(res, msg) {
@@ -3553,6 +3610,50 @@ function mount(router, deps) {
3553
3610
  });
3554
3611
  }
3555
3612
 
3613
+ // Recently viewed — the signed-in customer's newest-first browse
3614
+ // history. Views are recorded server-side on the (container-rendered)
3615
+ // PDP; this surface lets the customer review + clear that history.
3616
+ if (deps.recentlyViewed) {
3617
+ function _rvAuth(req, res) {
3618
+ var auth;
3619
+ try { auth = _currentCustomer(req); }
3620
+ catch (e) {
3621
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
3622
+ throw e;
3623
+ }
3624
+ if (!auth) {
3625
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
3626
+ res.end ? res.end() : res.send("");
3627
+ return null;
3628
+ }
3629
+ return auth;
3630
+ }
3631
+
3632
+ router.get("/account/recently-viewed", async function (req, res) {
3633
+ var auth = _rvAuth(req, res); if (!auth) return;
3634
+ // A read failure (table not migrated) degrades to the empty
3635
+ // state rather than 500-ing the account page.
3636
+ var rows = [];
3637
+ try { rows = await deps.recentlyViewed.forCustomer(auth.customer_id, { limit: 24 }); }
3638
+ catch (_e) { rows = []; }
3639
+ var products = [];
3640
+ for (var i = 0; i < rows.length; i += 1) {
3641
+ var card = await _decorateProductCard(rows[i].product_id);
3642
+ if (card) products.push(card);
3643
+ }
3644
+ var cartCount = await _cartCountForReq(req);
3645
+ _send(res, 200, renderRecentlyViewed({ products: products, shop_name: shopName, cart_count: cartCount }));
3646
+ });
3647
+
3648
+ router.post("/account/recently-viewed/clear", async function (req, res) {
3649
+ var auth = _rvAuth(req, res); if (!auth) return;
3650
+ try { await deps.recentlyViewed.purgeCustomer(auth.customer_id); }
3651
+ catch (_e) { /* drop-silent — a failed clear leaves history intact, no error surface needed */ }
3652
+ res.status(303); res.setHeader && res.setHeader("location", "/account/recently-viewed");
3653
+ return res.end ? res.end() : res.send("");
3654
+ });
3655
+ }
3656
+
3556
3657
  // Product reviews — submission requires a logged-in customer AND a
3557
3658
  // verified purchase of the product (the gate, not just a badge).
3558
3659
  // Only mounts when both the reviews primitive and an order handle
@@ -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.39",
7
- "tag": "v0.12.39",
6
+ "version": "0.12.40",
7
+ "tag": "v0.12.40",
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.40 (2026-05-24) — **`b.mdoc` — ISO 18013-5 mdoc / mDL issuer-data verification.** Verify the issuer-signed data of an ISO/IEC 18013-5 mdoc — the credential format behind mobile driving licences (mDL) and the ISO track of the EU Digital Identity Wallet. This is the relying-party side: confirm that the data elements a holder presents were signed by the issuer and have not been altered. An mdoc's IssuerSigned carries the disclosed data elements and an issuerAuth that is a COSE_Sign1 (b.cose) over a Mobile Security Object (MSO) holding a per-element digest. b.mdoc.verifyIssuerSigned verifies the COSE signature with the issuer certificate from the COSE x5chain header, parses the MSO, enforces its validityInfo window, and recomputes each disclosed element's digest (the full Tag-24 IssuerSignedItemBytes) to match it against the MSO constant-time — the integrity check that makes selective disclosure trustworthy. An absent or mismatched digest is refused. Signing algorithms follow b.cose verification (the classical ES256/384/512 + EdDSA that real mDL issuers use; the caller names the allowlist); opts.trustAnchorsPem additionally verifies the issuer certificate chain. This completes the credential trio alongside W3C VCDM (b.vc) and IETF SD-JWT VC (b.auth.sdJwtVc). Composes b.cose + b.cbor; no new runtime dependency. **Added:** *`b.mdoc.verifyIssuerSigned(issuerSigned, opts)`* — Takes the CBOR `IssuerSigned` map (the operator extracts it from the device response / QR) and returns `{ docType, version, digestAlgorithm, validityInfo, namespaces, signerCert, alg }`. Verifies the COSE_Sign1 `issuerAuth` against the mandatory `opts.algorithms` allowlist using the issuer certificate from its `x5chain` (label 33) header; parses the Tag-24 Mobile Security Object; enforces the MSO `validityInfo` window against `opts.at` (default now; must be a valid Date; malformed dates fail closed); and recomputes the digest of every disclosed `IssuerSignedItem` (over the full Tag-24 bytes, with the MSO `digestAlgorithm` — SHA-256/384/512) to match the MSO `valueDigests` constant-time — an absent or mismatched digest is refused with `mdoc/digest-mismatch`. `opts.expectedDocType` pins the document type; `opts.trustAnchorsPem` (a PEM string or array) additionally verifies the issuer certificate chain and validity at the asserted time. A malformed `x5chain` certificate is refused with a clean `mdoc/bad-cert`. The mdoc device-authentication half (the SessionTranscript-bound holder-binding proof) is a presentation-protocol concern and is not part of issuer-data verification.
12
+
11
13
  - v0.12.39 (2026-05-24) — **`b.vc` — W3C Verifiable Credentials 2.0 (issue / verify, JOSE + COSE securing).** Issue and verify W3C Verifiable Credentials (VC Data Model 2.0, a W3C Recommendation) secured per Securing Verifiable Credentials using JOSE and COSE (VC-JOSE-COSE, also a W3C Recommendation, May 2025). A verifiable credential is a tamper-evident, signed set of claims an issuer makes about a subject — a diploma, a membership, a license, an age assertion. Two securing mechanisms are supported, both signing the credential itself (no JWT/CWT claims wrapper): JOSE produces a compact JWS with the vc+jwt media type, signed with ES256/384/512 or EdDSA; COSE produces a COSE_Sign1 (application/vc+cose) over b.cose, which also accepts ML-DSA-87 for PQC-forward deployments. b.vc.verify auto-detects the form from the input, requires an algorithm allowlist, always refuses the JOSE none algorithm, re-checks the VCDM 2.0 structural rules, and enforces the validFrom / validUntil window. This is the W3C credential model, distinct from the IETF SD-JWT VC already at b.auth.sdJwtVc. Composes b.cose; no new runtime dependency. **Added:** *`b.vc.issue(credential, opts)` / `b.vc.verify(secured, opts)`* — `issue` validates the credential against the VCDM 2.0 structural rules (the `credentials/v2` context first, a `VerifiableCredential` type, an issuer, a credential subject) and signs it: `securing: "jose"` returns a compact JWS string (`typ` header `vc+jwt`), `securing: "cose"` returns COSE_Sign1 bytes (`typ` header `application/vc+cose`, content type `application/vc`) via `b.cose`. The credential is the exact signed payload — no JWT/CWT claims are injected. `verify` auto-detects the securing form from the input (compact-JWS string vs. COSE_Sign1 bytes), verifies the signature against the mandatory `opts.algorithms` allowlist (the JOSE `none` algorithm is always refused), re-checks the structural rules, enforces the `validFrom` / `validUntil` window against `opts.at` (default now; must be a valid Date), and optionally matches `opts.expectedIssuer` against the credential issuer id. Returns `{ credential, securing, alg, issuer }`.
12
14
 
13
15
  - v0.12.38 (2026-05-24) — **`b.tsa` — RFC 3161 trusted timestamping client (build / parse / verify).** A timestamp authority binds a hash of your data to a trusted time, producing a token that proves the data existed at that instant — timestamp a release artifact, an audit-log checkpoint, a b.scitt signed statement, or a contract. b.tsa is the requester/verifier side of RFC 3161: buildRequest produces the DER TimeStampReq (the message imprint plus an optional nonce and a cert request), parseResponse reads the TimeStampResp (PKIStatus, failure-info bits, and the token), and verifyToken checks a token against your data and returns the asserted time. Verification is done in full per §2.4.2 / §2.3: the token is a CMS SignedData (b.cms) whose eContentType must be id-ct-TSTInfo; the message imprint must equal the hash of your data (constant-time); a sent nonce must round-trip; the signer certificate's extendedKeyUsage must be a critical, sole id-kp-timeStamping; and the CMS signature over the signed attributes must verify after the messageDigest attribute is matched to the recomputed eContent digest. An optional trust-anchor set verifies the certificate chain and validity at the asserted time. The HTTP transport to the TSA is the operator's to make. Composes b.cms and the in-tree ASN.1 DER codec; no new runtime dependency. **Added:** *`b.tsa.buildRequest(data, opts?)` / `b.tsa.parseResponse(der)` / `b.tsa.verifyToken(token, opts)`* — `buildRequest` returns `{ der, nonce, hashAlg, messageImprint }`; the imprint hash defaults to SHA-512 and may be SHA-256/384/512 or SHA3-256/512, a random 64-bit nonce and a certificate request are included by default, and a pre-hashed input is accepted with `hashed: true`. `parseResponse` returns `{ granted, status, statusString, failInfo, token }`, decoding the PKIFailureInfo bits for a non-granted response rather than throwing. `verifyToken` enforces the imprint match (`opts.data` or `opts.hash`), the nonce round-trip, the critical/sole `id-kp-timeStamping` EKU, and the CMS signature, returning `{ genTime, policy, serialHex, accuracy, hashAlg, signerCertPem }`; pass `opts.trustAnchorsPem` to also verify the certificate chain and validity at the asserted time. Timestamp tokens are third-party artifacts, so verification accepts the classical RSA (PKCS#1 v1.5 and PSS) and ECDSA-over-SHA-2 signatures that public TSAs emit — the same consume-what-exists posture as `b.cose` verification, not a framework signing default.
@@ -132,6 +132,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
132
132
  - **SCITT signed statements** — `b.scitt` sign/verify a signed, attributable claim about an artifact (signed SBOM, build attestation, release approval) over `b.cose`: the issuer + subject bind in the integrity-protected CWT_Claims header (RFC 9597); verification refuses any statement missing the iss/sub binding. The issuer side, on finalized RFCs; the transparency receipt (COSE Receipts draft) opts in on publication
133
133
  - **Trusted timestamping** — `b.tsa` RFC 3161 timestamp client: `buildRequest` a TimeStampReq, `parseResponse`, and `verifyToken` against your data — the message imprint, sent nonce, critical/sole `id-kp-timeStamping` EKU, and CMS signature are all checked, with optional certificate-chain verification. Timestamp a release artifact, audit checkpoint, or signed statement against any RFC 3161 TSA. Composes `b.cms` + the in-tree ASN.1 DER codec
134
134
  - **Verifiable Credentials** — `b.vc` W3C Verifiable Credentials Data Model 2.0 (VC-JOSE-COSE): `issue` / `verify` a signed credential as a compact JWS (`vc+jwt`, ES256/384/512 + EdDSA) or a COSE_Sign1 (`vc+cose`, + ML-DSA-87) over `b.cose`. VCDM structural + `validFrom`/`validUntil` checks; the JOSE `none` algorithm is always refused. The W3C model, distinct from the IETF SD-JWT VC at `b.auth.sdJwtVc`
135
+ - **Mobile credentials (mDL)** — `b.mdoc` ISO/IEC 18013-5 issuer-data verification: `verifyIssuerSigned` checks the COSE_Sign1 IssuerAuth (issuer cert from the `x5chain` header), the Mobile Security Object validity window, and every disclosed element's digest against the MSO `valueDigests` (the selective-disclosure integrity check), with optional issuer-chain verification. The ISO credential ecosystem alongside `b.vc` and `b.auth.sdJwtVc`. Composes `b.cose` + `b.cbor`
135
136
  - **Document parsers** — `b.parsers` (XML / TOML / YAML / .env); `b.config` (schema-validated env)
136
137
  - **File-type detection** — `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.)
137
138
  ### Content-safety gates
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.12.39",
4
- "createdAt": "2026-05-25T02:42:46.281Z",
3
+ "frameworkVersion": "0.12.40",
4
+ "createdAt": "2026-05-25T03:52:08.305Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -40319,6 +40319,36 @@
40319
40319
  }
40320
40320
  }
40321
40321
  },
40322
+ "mdoc": {
40323
+ "type": "object",
40324
+ "members": {
40325
+ "DIGEST_ALGS": {
40326
+ "type": "object",
40327
+ "members": {
40328
+ "SHA-256": {
40329
+ "type": "primitive",
40330
+ "valueType": "string"
40331
+ },
40332
+ "SHA-384": {
40333
+ "type": "primitive",
40334
+ "valueType": "string"
40335
+ },
40336
+ "SHA-512": {
40337
+ "type": "primitive",
40338
+ "valueType": "string"
40339
+ }
40340
+ }
40341
+ },
40342
+ "MdocError": {
40343
+ "type": "function",
40344
+ "arity": 4
40345
+ },
40346
+ "verifyIssuerSigned": {
40347
+ "type": "function",
40348
+ "arity": 2
40349
+ }
40350
+ }
40351
+ },
40322
40352
  "metrics": {
40323
40353
  "type": "object",
40324
40354
  "members": {
@@ -462,6 +462,7 @@ module.exports = {
462
462
  scitt: require("./lib/scitt"),
463
463
  tsa: require("./lib/tsa"),
464
464
  vc: require("./lib/vc"),
465
+ mdoc: require("./lib/mdoc"),
465
466
  queue: queue,
466
467
  logStream: logStream,
467
468
  redact: redact,
@@ -0,0 +1,305 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mdoc
4
+ * @nav Crypto
5
+ * @title ISO mdoc / mDL (ISO 18013-5)
6
+ *
7
+ * @intro
8
+ * Verify the issuer-signed data of an ISO/IEC 18013-5 mdoc — the
9
+ * credential format behind mobile driving licences (mDL) and the ISO
10
+ * track of the EU Digital Identity Wallet. This is the relying-party
11
+ * side: confirm that the data elements a holder presents were signed
12
+ * by the issuer and have not been altered.
13
+ *
14
+ * An mdoc's <code>IssuerSigned</code> structure carries the disclosed
15
+ * data elements (<code>nameSpaces</code>) and an <code>issuerAuth</code>
16
+ * that is a COSE_Sign1 (<code>b.cose</code>) over a Mobile Security
17
+ * Object (MSO). The MSO holds, per namespace, a SHA-256/384/512 digest
18
+ * of every issued element. <code>b.mdoc.verifyIssuerSigned</code>
19
+ * verifies the COSE signature with the issuer certificate carried in
20
+ * the COSE <code>x5chain</code> (label 33), parses the MSO, enforces
21
+ * its <code>validityInfo</code> window, and — the integrity check that
22
+ * makes selective disclosure trustworthy — recomputes the digest of
23
+ * every disclosed element (the full Tag-24 <code>IssuerSignedItemBytes</code>)
24
+ * and matches it against the MSO, constant-time. A disclosed element
25
+ * whose digest is absent or mismatched is refused.
26
+ *
27
+ * Signing algorithms follow <code>b.cose</code> verification: the
28
+ * classical ES256 / 384 / 512 and EdDSA that real mDL issuers use are
29
+ * accepted (consume-what-exists; the caller names the allowlist).
30
+ * <code>opts.trustAnchorsPem</code> additionally verifies the issuer
31
+ * certificate chain and its validity at the asserted time.
32
+ *
33
+ * <strong>Scope.</strong> This is issuer-data authentication
34
+ * (ISO 18013-5 §9.1.2.4) — the data is genuine and issuer-signed. The
35
+ * mdoc <em>device authentication</em> half (DeviceSigned / the
36
+ * SessionTranscript-bound holder-binding proof, §9.1.3) is deferred:
37
+ * it needs the live session transcript a verifier negotiates, so it is
38
+ * a presentation-protocol concern rather than a credential check.
39
+ * Composes <code>b.cose</code> + <code>b.cbor</code>; no new runtime
40
+ * dependency. Distinct from W3C VCDM (<code>b.vc</code>) and IETF
41
+ * SD-JWT VC (<code>b.auth.sdJwtVc</code>) — the three credential
42
+ * ecosystems.
43
+ *
44
+ * @card
45
+ * ISO 18013-5 mdoc / mDL issuer-data verification — checks the
46
+ * COSE_Sign1 IssuerAuth, the MSO validity window, and every disclosed
47
+ * element's digest against the Mobile Security Object. Composes
48
+ * b.cose + b.cbor; device-auth holder-binding deferred.
49
+ */
50
+
51
+ var nodeCrypto = require("node:crypto");
52
+ var C = require("./constants");
53
+ var cbor = require("./cbor");
54
+ var cose = require("./cose");
55
+ var bCrypto = require("./crypto");
56
+ var validateOpts = require("./validate-opts");
57
+ var { defineClass } = require("./framework-error");
58
+
59
+ var MdocError = defineClass("MdocError", { alwaysPermanent: true });
60
+
61
+ var HDR_X5CHAIN = 33; // allow:raw-byte-literal allow:raw-time-literal — x5chain COSE header label (RFC 9360 is a spec number, not a size/duration)
62
+ var TAG_ENCODED_CBOR = 24; // allow:raw-byte-literal — RFC 8949 §3.4.5.1 embedded-CBOR tag
63
+ // Tags ISO 18013-5 uses in issuer data: tdate(0), epoch(1), embedded
64
+ // CBOR(24), full-date(1004, RFC 8943). Bounded — others are refused.
65
+ var ALLOWED_TAGS = [0, 1, TAG_ENCODED_CBOR, 1004];
66
+ var DIGEST_ALGS = { "SHA-256": "sha256", "SHA-384": "sha384", "SHA-512": "sha512" };
67
+
68
+ function _bytes(x, what) {
69
+ if (Buffer.isBuffer(x)) return x;
70
+ if (x instanceof Uint8Array) return Buffer.from(x);
71
+ throw new MdocError("mdoc/bad-input", "mdoc: " + what + " must be a Buffer / Uint8Array of CBOR");
72
+ }
73
+
74
+ // validityInfo dates are tdate (Tag 0, an RFC 3339 string) or epoch
75
+ // (Tag 1). Returns epoch-ms; fails closed on a malformed value.
76
+ function _validityMs(v, name) {
77
+ var raw = (v instanceof cbor.Tag) ? v.value : v;
78
+ if (typeof raw === "string") {
79
+ var ms = Date.parse(raw);
80
+ if (!isFinite(ms)) throw new MdocError("mdoc/bad-validity", "mdoc: validityInfo." + name + " is not a valid date: " + raw);
81
+ return ms;
82
+ }
83
+ if (typeof raw === "number" && isFinite(raw)) return raw * C.TIME.seconds(1); // epoch seconds → ms
84
+ throw new MdocError("mdoc/bad-validity", "mdoc: validityInfo." + name + " is missing or malformed");
85
+ }
86
+
87
+ function _mapGet(m, k) { return m instanceof Map ? m.get(k) : (m ? m[k] : undefined); }
88
+
89
+ /**
90
+ * @primitive b.mdoc.verifyIssuerSigned
91
+ * @signature b.mdoc.verifyIssuerSigned(issuerSigned, opts)
92
+ * @since 0.12.40
93
+ * @status experimental
94
+ * @compliance gdpr, soc2
95
+ * @related b.cose.verify, b.vc.verify
96
+ *
97
+ * Verify the issuer-signed data of an ISO 18013-5 mdoc and return the
98
+ * disclosed elements. <code>issuerSigned</code> is the CBOR
99
+ * <code>IssuerSigned</code> map (the operator extracts it from the
100
+ * device response / QR). The COSE_Sign1 <code>issuerAuth</code> is
101
+ * verified with the issuer certificate from its <code>x5chain</code>
102
+ * header against the mandatory <code>opts.algorithms</code> allowlist;
103
+ * the MSO <code>validityInfo</code> window is enforced; and every
104
+ * disclosed element's digest is matched against the Mobile Security
105
+ * Object (a mismatch or absence is refused). Pass
106
+ * <code>opts.trustAnchorsPem</code> to also verify the issuer
107
+ * certificate chain.
108
+ *
109
+ * @opts
110
+ * {
111
+ * algorithms: string[], // required — accepted COSE alg names (ES256/384/512, EdDSA)
112
+ * trustAnchorsPem: string|string[], // optional issuer roots — enables chain + validity verification
113
+ * expectedDocType: string, // require the MSO docType to match (e.g. "org.iso.18013.5.1.mDL")
114
+ * at: Date, // validity instant (default now); must be a valid Date
115
+ * maxBytes: number, // forwarded to b.cbor.decode
116
+ * maxDepth: number,
117
+ * }
118
+ *
119
+ * @example
120
+ * var out = await b.mdoc.verifyIssuerSigned(issuerSignedBytes, {
121
+ * algorithms: ["ES256"], expectedDocType: "org.iso.18013.5.1.mDL",
122
+ * });
123
+ * // → { docType, validityInfo, namespaces: { "org.iso.18013.5.1": { family_name, age_over_18, … } }, signerCert, alg }
124
+ */
125
+ async function verifyIssuerSigned(issuerSigned, opts) {
126
+ validateOpts.requireObject(opts, "mdoc.verifyIssuerSigned", MdocError);
127
+ validateOpts(opts, ["algorithms", "trustAnchorsPem", "expectedDocType", "at", "maxBytes", "maxDepth"], "mdoc.verifyIssuerSigned");
128
+ if (!Array.isArray(opts.algorithms) || opts.algorithms.length === 0) {
129
+ throw new MdocError("mdoc/algorithms-required", "mdoc.verifyIssuerSigned: opts.algorithms is required");
130
+ }
131
+ var at = new Date();
132
+ if (opts.at !== undefined && opts.at !== null) {
133
+ if (!(opts.at instanceof Date) || !isFinite(opts.at.getTime())) {
134
+ throw new MdocError("mdoc/bad-at", "mdoc.verifyIssuerSigned: opts.at must be a valid Date");
135
+ }
136
+ at = opts.at;
137
+ }
138
+ var decodeOpts = { allowedTags: ALLOWED_TAGS, maxBytes: opts.maxBytes, maxDepth: opts.maxDepth };
139
+
140
+ var top = cbor.decode(_bytes(issuerSigned, "issuerSigned"), decodeOpts);
141
+ var nameSpaces = _mapGet(top, "nameSpaces");
142
+ var issuerAuth = _mapGet(top, "issuerAuth");
143
+ if (!Array.isArray(issuerAuth) || issuerAuth.length !== 4) {
144
+ throw new MdocError("mdoc/malformed", "mdoc.verifyIssuerSigned: issuerAuth must be a COSE_Sign1 (4-element array)");
145
+ }
146
+
147
+ // The signer certificate rides in the COSE x5chain (label 33): a
148
+ // single cert bstr or an array of bstrs, leaf first.
149
+ var unprotected = issuerAuth[1];
150
+ var x5 = _mapGet(unprotected, HDR_X5CHAIN);
151
+ var chain = Array.isArray(x5) ? x5 : (x5 != null ? [x5] : []);
152
+ if (!chain.length || !Buffer.isBuffer(chain[0])) {
153
+ throw new MdocError("mdoc/no-cert", "mdoc.verifyIssuerSigned: issuerAuth has no x5chain certificate (label 33)");
154
+ }
155
+ // The x5chain certificate is attacker-controlled — a malformed DER
156
+ // must surface as a clean error, not a raw OpenSSL throw.
157
+ var signerCert;
158
+ try { signerCert = new nodeCrypto.X509Certificate(chain[0]); }
159
+ catch (e) {
160
+ throw new MdocError("mdoc/bad-cert", "mdoc.verifyIssuerSigned: x5chain certificate is not valid DER: " + ((e && e.message) || e));
161
+ }
162
+
163
+ // Verify the COSE_Sign1 signature with the embedded signer key.
164
+ var coseBytes = cbor.encode(issuerAuth);
165
+ var verified = await cose.verify(coseBytes, {
166
+ algorithms: opts.algorithms,
167
+ keyResolver: function () { return signerCert.publicKey; },
168
+ maxBytes: opts.maxBytes,
169
+ maxDepth: opts.maxDepth,
170
+ });
171
+
172
+ // payload = Tag 24 ( bstr .cbor MSO ).
173
+ var payloadTag = cbor.decode(verified.payload, decodeOpts);
174
+ var msoBytes = (payloadTag instanceof cbor.Tag && payloadTag.tag === TAG_ENCODED_CBOR) ? payloadTag.value : null;
175
+ if (!Buffer.isBuffer(msoBytes)) {
176
+ throw new MdocError("mdoc/malformed", "mdoc.verifyIssuerSigned: issuerAuth payload is not a Tag-24 MobileSecurityObject");
177
+ }
178
+ var mso = cbor.decode(msoBytes, decodeOpts);
179
+
180
+ var digestAlgName = _mapGet(mso, "digestAlgorithm");
181
+ var digestNode = DIGEST_ALGS[digestAlgName];
182
+ if (!digestNode) {
183
+ throw new MdocError("mdoc/bad-digest-alg", "mdoc.verifyIssuerSigned: unsupported MSO digestAlgorithm '" + digestAlgName + "'");
184
+ }
185
+ var docType = _mapGet(mso, "docType");
186
+ if (opts.expectedDocType !== undefined && docType !== opts.expectedDocType) {
187
+ throw new MdocError("mdoc/doctype-mismatch", "mdoc.verifyIssuerSigned: MSO docType '" + docType + "' does not match expectedDocType");
188
+ }
189
+
190
+ // validityInfo window (fail closed on malformed dates).
191
+ var vi = _mapGet(mso, "validityInfo");
192
+ if (!(vi instanceof Map) && (!vi || typeof vi !== "object")) {
193
+ throw new MdocError("mdoc/malformed", "mdoc.verifyIssuerSigned: MSO has no validityInfo");
194
+ }
195
+ var nowMs = at.getTime();
196
+ var validFromMs = _validityMs(_mapGet(vi, "validFrom"), "validFrom");
197
+ var validUntilMs = _validityMs(_mapGet(vi, "validUntil"), "validUntil");
198
+ if (nowMs < validFromMs) throw new MdocError("mdoc/not-yet-valid", "mdoc.verifyIssuerSigned: credential not yet valid");
199
+ if (nowMs > validUntilMs) throw new MdocError("mdoc/expired", "mdoc.verifyIssuerSigned: credential validity has passed");
200
+
201
+ // Match every disclosed element's digest against the MSO. The digest
202
+ // covers the full Tag-24 IssuerSignedItemBytes (ISO 18013-5 §9.1.2.5).
203
+ var valueDigests = _mapGet(mso, "valueDigests");
204
+ var out = {};
205
+ if (nameSpaces instanceof Map) {
206
+ var nsNames = Array.from(nameSpaces.keys());
207
+ for (var ni = 0; ni < nsNames.length; ni += 1) {
208
+ var ns = nsNames[ni];
209
+ var items = nameSpaces.get(ns);
210
+ var nsDigests = _mapGet(valueDigests, ns);
211
+ if (!Array.isArray(items) || !(nsDigests instanceof Map)) {
212
+ throw new MdocError("mdoc/malformed", "mdoc.verifyIssuerSigned: namespace '" + ns + "' has no matching valueDigests");
213
+ }
214
+ out[ns] = {};
215
+ var seen = Object.create(null); // dup-elementIdentifier guard (proto-safe)
216
+ for (var ii = 0; ii < items.length; ii += 1) {
217
+ var item = items[ii];
218
+ if (!(item instanceof cbor.Tag) || item.tag !== TAG_ENCODED_CBOR || !Buffer.isBuffer(item.value)) {
219
+ throw new MdocError("mdoc/malformed", "mdoc.verifyIssuerSigned: IssuerSignedItem is not a Tag-24 byte string");
220
+ }
221
+ var itemBytes = cbor.encode(new cbor.Tag(TAG_ENCODED_CBOR, item.value));
222
+ var digest = nodeCrypto.createHash(digestNode).update(itemBytes).digest();
223
+ var inner = cbor.decode(item.value, decodeOpts);
224
+ var digestID = _mapGet(inner, "digestID");
225
+ var expected = nsDigests.get(digestID);
226
+ if (!Buffer.isBuffer(expected) || !bCrypto.timingSafeEqual(digest, expected)) {
227
+ throw new MdocError("mdoc/digest-mismatch",
228
+ "mdoc.verifyIssuerSigned: disclosed element (digestID " + digestID + ", namespace " + ns + ") does not match the MSO");
229
+ }
230
+ // Refuse a duplicate elementIdentifier within a namespace — two
231
+ // signed values for one element is ambiguous; fail closed rather
232
+ // than silently keep the last.
233
+ var elementId = _mapGet(inner, "elementIdentifier");
234
+ if (seen[elementId]) {
235
+ throw new MdocError("mdoc/duplicate-element",
236
+ "mdoc.verifyIssuerSigned: namespace '" + ns + "' has duplicate elementIdentifier '" + elementId + "'");
237
+ }
238
+ seen[elementId] = true;
239
+ out[ns][elementId] = _mapGet(inner, "elementValue");
240
+ }
241
+ }
242
+ }
243
+
244
+ // Optional issuer chain + validity at the asserted time.
245
+ if (opts.trustAnchorsPem !== undefined && opts.trustAnchorsPem !== null) {
246
+ var anchors = typeof opts.trustAnchorsPem === "string" ? [opts.trustAnchorsPem] : opts.trustAnchorsPem;
247
+ if (!Array.isArray(anchors) || anchors.length === 0 ||
248
+ !anchors.every(function (a) { return typeof a === "string" && a.length > 0; })) {
249
+ throw new MdocError("mdoc/bad-trust-anchors", "mdoc.verifyIssuerSigned: trustAnchorsPem must be a non-empty PEM string or array");
250
+ }
251
+ _verifyChain(chain, anchors, at);
252
+ }
253
+
254
+ return {
255
+ docType: docType,
256
+ version: _mapGet(mso, "version"),
257
+ digestAlgorithm: digestAlgName,
258
+ validityInfo: { validFrom: new Date(validFromMs), validUntil: new Date(validUntilMs) },
259
+ namespaces: out,
260
+ signerCert: signerCert.toString(),
261
+ alg: verified.alg,
262
+ };
263
+ }
264
+
265
+ // Verify the leaf (chain[0]) chains to a supplied anchor and every cert
266
+ // is valid at `at`. Intermediates in the x5chain are consulted.
267
+ function _verifyChain(chainDer, anchorsPem, at) {
268
+ var anchors = anchorsPem.map(function (p) { return new nodeCrypto.X509Certificate(p); });
269
+ var pool = chainDer.map(function (d) { return new nodeCrypto.X509Certificate(d); });
270
+ var current = pool[0];
271
+ var atMs = at.getTime();
272
+ var steps = 0;
273
+ while (steps <= pool.length + 1) {
274
+ _assertValidAt(current, atMs);
275
+ for (var a = 0; a < anchors.length; a += 1) {
276
+ if (_issued(anchors[a], current)) { _assertValidAt(anchors[a], atMs); return; }
277
+ if (current.fingerprint256 === anchors[a].fingerprint256) return;
278
+ }
279
+ var parent = null;
280
+ for (var p = 0; p < pool.length; p += 1) {
281
+ if (pool[p].fingerprint256 !== current.fingerprint256 && _issued(pool[p], current)) { parent = pool[p]; break; }
282
+ }
283
+ if (!parent) {
284
+ throw new MdocError("mdoc/untrusted-chain", "mdoc.verifyIssuerSigned: issuer certificate does not chain to a supplied trust anchor");
285
+ }
286
+ current = parent;
287
+ steps += 1;
288
+ }
289
+ throw new MdocError("mdoc/chain-loop", "mdoc.verifyIssuerSigned: certificate chain did not terminate");
290
+ }
291
+ function _issued(issuer, subject) {
292
+ try { return subject.checkIssued(issuer) && subject.verify(issuer.publicKey); }
293
+ catch (_e) { return false; }
294
+ }
295
+ function _assertValidAt(cert, atMs) {
296
+ if (atMs < cert.validFromDate.getTime() || atMs > cert.validToDate.getTime()) {
297
+ throw new MdocError("mdoc/cert-expired", "mdoc.verifyIssuerSigned: certificate '" + cert.subject + "' is not valid at the asserted time");
298
+ }
299
+ }
300
+
301
+ module.exports = {
302
+ verifyIssuerSigned: verifyIssuerSigned,
303
+ DIGEST_ALGS: DIGEST_ALGS,
304
+ MdocError: MdocError,
305
+ };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.39",
3
+ "version": "0.12.40",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.12.40",
4
+ "date": "2026-05-24",
5
+ "headline": "`b.mdoc` — ISO 18013-5 mdoc / mDL issuer-data verification",
6
+ "summary": "Verify the issuer-signed data of an ISO/IEC 18013-5 mdoc — the credential format behind mobile driving licences (mDL) and the ISO track of the EU Digital Identity Wallet. This is the relying-party side: confirm that the data elements a holder presents were signed by the issuer and have not been altered. An mdoc's IssuerSigned carries the disclosed data elements and an issuerAuth that is a COSE_Sign1 (b.cose) over a Mobile Security Object (MSO) holding a per-element digest. b.mdoc.verifyIssuerSigned verifies the COSE signature with the issuer certificate from the COSE x5chain header, parses the MSO, enforces its validityInfo window, and recomputes each disclosed element's digest (the full Tag-24 IssuerSignedItemBytes) to match it against the MSO constant-time — the integrity check that makes selective disclosure trustworthy. An absent or mismatched digest is refused. Signing algorithms follow b.cose verification (the classical ES256/384/512 + EdDSA that real mDL issuers use; the caller names the allowlist); opts.trustAnchorsPem additionally verifies the issuer certificate chain. This completes the credential trio alongside W3C VCDM (b.vc) and IETF SD-JWT VC (b.auth.sdJwtVc). Composes b.cose + b.cbor; no new runtime dependency.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.mdoc.verifyIssuerSigned(issuerSigned, opts)`",
13
+ "body": "Takes the CBOR `IssuerSigned` map (the operator extracts it from the device response / QR) and returns `{ docType, version, digestAlgorithm, validityInfo, namespaces, signerCert, alg }`. Verifies the COSE_Sign1 `issuerAuth` against the mandatory `opts.algorithms` allowlist using the issuer certificate from its `x5chain` (label 33) header; parses the Tag-24 Mobile Security Object; enforces the MSO `validityInfo` window against `opts.at` (default now; must be a valid Date; malformed dates fail closed); and recomputes the digest of every disclosed `IssuerSignedItem` (over the full Tag-24 bytes, with the MSO `digestAlgorithm` — SHA-256/384/512) to match the MSO `valueDigests` constant-time — an absent or mismatched digest is refused with `mdoc/digest-mismatch`. `opts.expectedDocType` pins the document type; `opts.trustAnchorsPem` (a PEM string or array) additionally verifies the issuer certificate chain and validity at the asserted time. A malformed `x5chain` certificate is refused with a clean `mdoc/bad-cert`. The mdoc device-authentication half (the SessionTranscript-bound holder-binding proof) is a presentation-protocol concern and is not part of issuer-data verification."
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
@@ -2252,6 +2252,24 @@ async function testNoDuplicateCodeBlocks() {
2252
2252
  ],
2253
2253
  reason: "v0.12.33 — opts / structure validation prelude (`validateOpts(allowedKeys) + chained required-field + typeof guards + typed-error throw`). cose.verify validates a COSE_Sign1 opts blob + decoded structure (RFC 9052); the peers each validate a distinct spec's shape (SD-JWT-VC issuer opts / break-glass policy set / JSCalendar object / DDL dual-control declaration / DSR request / FedCM well-known manifest / Android Asset Links / heartbeat config). Each throws a primitive-specific typed error; the shingle is the validateOpts-then-guard idiom, not behaviour. Same family as the v0.12.29 input-shape-validation cluster.",
2254
2254
  },
2255
+ {
2256
+ mode: "family-subset",
2257
+ files: [
2258
+ "lib/mdoc.js:verifyIssuerSigned",
2259
+ "lib/tsa.js:verifyToken",
2260
+ "lib/vc.js:verify",
2261
+ ],
2262
+ reason: "v0.12.40 — signature-verify entry preamble shared by three credential / token verifiers: `validateOpts(allowedKeys) + mandatory algorithms-allowlist check + opts.at valid-Date guard + publicKey/keyResolver presence check`, then divergent domain logic. tsa.verifyToken verifies an RFC 3161 timestamp token (CMS SignedData + message-imprint + EKU); vc.verify verifies a W3C VC-JOSE-COSE credential (JWS/COSE + VCDM structural + validity window); mdoc.verifyIssuerSigned verifies an ISO 18013-5 mdoc (COSE_Sign1 IssuerAuth + MSO valueDigests matching). Each consumes a different wire format, returns a different shape, and throws a primitive-specific typed error — the shingle is the validate-then-guard preamble, not behaviour. Same family as the v0.12.33 cose.verify cluster.",
2263
+ },
2264
+ {
2265
+ mode: "family-subset",
2266
+ files: [
2267
+ "lib/dual-control.js:create",
2268
+ "lib/mdoc.js:verifyIssuerSigned",
2269
+ "lib/tsa.js:verifyToken",
2270
+ ],
2271
+ reason: "v0.12.40 — validateOpts-then-guard prelude shared between a create-style validator (dual-control.create builds a two-person-rule grant after validating its opts) and the timestamp / mdoc verifiers. The common shingle is the `validateOpts(allowedKeys) + chained guard + typed-error` idiom; the bodies diverge entirely (dual-control persists a control record; tsa/mdoc verify cryptographic structures). Same validate-then-guard family as the v0.12.29 / v0.12.33 clusters.",
2272
+ },
2255
2273
  {
2256
2274
  mode: "family-subset",
2257
2275
  files: [
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ /**
3
+ * Layer 0 — b.mdoc (ISO 18013-5 mdoc / mDL issuer-data verification).
4
+ * A pure-node mock issuer builds an IssuerSigned structure (COSE_Sign1
5
+ * IssuerAuth over a Tag-24 MobileSecurityObject, with the signer cert in
6
+ * the x5chain header) so the verifier's full path is exercised: COSE
7
+ * signature, MSO validity window, per-element digest matching against
8
+ * the MSO, selective disclosure, and the tamper / expiry / docType /
9
+ * malformed-validity refusal paths. The trust core (COSE_Sign1 + CBOR)
10
+ * is the framework's tested b.cose / b.cbor substrate.
11
+ */
12
+
13
+ var b = require("../../index");
14
+ var helpers = require("../helpers");
15
+ var check = helpers.check;
16
+ var asn1 = require("../../lib/asn1-der");
17
+ var cbor = b.cbor;
18
+ var nodeCrypto = require("node:crypto");
19
+
20
+ var NS = "org.iso.18013.5.1";
21
+ var DOCTYPE = "org.iso.18013.5.1.mDL";
22
+
23
+ function _algId(oid, withNull) {
24
+ return withNull ? asn1.writeSequence([asn1.writeOid(oid), asn1.writeNull()]) : asn1.writeSequence([asn1.writeOid(oid)]);
25
+ }
26
+ function _name(cn) {
27
+ return asn1.writeSequence([asn1.writeSet([asn1.writeSequence([asn1.writeOid("2.5.4.3"), asn1.writeUtf8String(cn)])])]);
28
+ }
29
+ function _utc(d) { return asn1.writeNode(0x17, Buffer.from(d.toISOString().replace(/[-:T]/g, "").slice(2, 14) + "Z", "ascii")); }
30
+
31
+ // Minimal self-signed EC cert (mdoc issuer-data verify reads the key,
32
+ // not an EKU). Returns { certDer, key, pem }.
33
+ function _makeCert(cn) {
34
+ var kp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
35
+ var spki = kp.publicKey.export({ type: "spki", format: "der" });
36
+ var sa = _algId("1.2.840.10045.4.3.2", false);
37
+ var nm = _name(cn || "mDL Issuer");
38
+ var now = Date.now();
39
+ var tbs = asn1.writeSequence([
40
+ asn1.writeContextExplicit(0, asn1.writeInteger(Buffer.from([2]))),
41
+ asn1.writeInteger(Buffer.from([0x2a])), sa, nm,
42
+ asn1.writeSequence([_utc(new Date(now - 86400000)), _utc(new Date(now + 86400000 * 3650))]),
43
+ nm, spki,
44
+ ]);
45
+ var certDer = asn1.writeSequence([tbs, sa, asn1.writeBitString(nodeCrypto.sign("sha256", tbs, kp.privateKey), 0)]);
46
+ return { certDer: certDer, key: kp.privateKey, pem: new nodeCrypto.X509Certificate(certDer).toString() };
47
+ }
48
+
49
+ // Build an IssuerSigned (CBOR bytes). opts: { elements, validFrom,
50
+ // validUntil, docType, digestAlg } — defaults are a valid mDL.
51
+ async function _makeMdoc(cert, opts) {
52
+ opts = opts || {};
53
+ var elements = opts.elements || [["family_name", "Doe"], ["age_over_18", true], ["given_name", "Jane"]];
54
+ var digestNode = opts.digestNode || "sha256";
55
+ var digestAlg = opts.digestAlg || "SHA-256";
56
+ var now = Date.now();
57
+ var items = [];
58
+ var digests = new Map();
59
+ elements.forEach(function (el, i) {
60
+ var inner = cbor.encode(new Map([
61
+ ["digestID", i], ["random", nodeCrypto.randomBytes(32)],
62
+ ["elementIdentifier", el[0]], ["elementValue", el[1]],
63
+ ]));
64
+ items.push(new cbor.Tag(24, inner));
65
+ digests.set(i, nodeCrypto.createHash(digestNode).update(cbor.encode(new cbor.Tag(24, inner))).digest());
66
+ });
67
+ var validFromMs = opts.validFrom != null ? opts.validFrom : now - 86400000;
68
+ var validUntilMs = opts.validUntil != null ? opts.validUntil : now + 86400000 * 3650;
69
+ var validUntilTag = opts.validUntilRaw !== undefined ? opts.validUntilRaw : new cbor.Tag(0, new Date(validUntilMs).toISOString());
70
+ var mso = new Map([
71
+ ["version", "1.0"], ["digestAlgorithm", digestAlg], ["docType", opts.docType || DOCTYPE],
72
+ ["valueDigests", new Map([[NS, digests]])],
73
+ ["validityInfo", new Map([
74
+ ["signed", new cbor.Tag(0, new Date(now).toISOString())],
75
+ ["validFrom", new cbor.Tag(0, new Date(validFromMs).toISOString())],
76
+ ["validUntil", validUntilTag],
77
+ ])],
78
+ ]);
79
+ var payload = cbor.encode(new cbor.Tag(24, cbor.encode(mso)));
80
+ var signed = await b.cose.sign(payload, { alg: opts.alg || "ES256", privateKey: cert.key, unprotectedHeaders: { 33: cert.certDer } });
81
+ var issuerAuth = cbor.decode(signed, { allowedTags: [18, 24] }).value;
82
+ return cbor.encode(new Map([["nameSpaces", new Map([[NS, items]])], ["issuerAuth", issuerAuth]]));
83
+ }
84
+
85
+ function testSurface() {
86
+ check("b.mdoc.verifyIssuerSigned is a function", typeof b.mdoc.verifyIssuerSigned === "function");
87
+ check("b.mdoc.DIGEST_ALGS has SHA-256", b.mdoc.DIGEST_ALGS["SHA-256"] === "sha256");
88
+ check("b.mdoc.MdocError is a class", typeof b.mdoc.MdocError === "function");
89
+ }
90
+
91
+ async function testRoundTrip() {
92
+ var cert = _makeCert();
93
+ var mdoc = await _makeMdoc(cert);
94
+ var out = await b.mdoc.verifyIssuerSigned(mdoc, { algorithms: ["ES256"], expectedDocType: DOCTYPE });
95
+ check("verify: docType", out.docType === DOCTYPE);
96
+ check("verify: alg reported", out.alg === "ES256");
97
+ check("verify: digestAlgorithm", out.digestAlgorithm === "SHA-256");
98
+ check("verify: disclosed elements extracted", out.namespaces[NS].family_name === "Doe" && out.namespaces[NS].age_over_18 === true && out.namespaces[NS].given_name === "Jane");
99
+ check("verify: validityInfo dates returned", out.validityInfo.validUntil instanceof Date);
100
+ check("verify: signerCert PEM returned", /BEGIN CERTIFICATE/.test(out.signerCert));
101
+
102
+ // chain verify against the self-signed issuer as anchor
103
+ var ok = await b.mdoc.verifyIssuerSigned(mdoc, { algorithms: ["ES256"], trustAnchorsPem: cert.pem });
104
+ check("verify: chain to issuer anchor (string)", ok.docType === DOCTYPE);
105
+ }
106
+
107
+ async function testDigestAndSignatureRefusals() {
108
+ var cert = _makeCert();
109
+ var mdoc = await _makeMdoc(cert);
110
+
111
+ // Tamper a disclosed element's value: re-pack one IssuerSignedItem
112
+ // with a changed value but keep the MSO digest → digest mismatch.
113
+ var top = cbor.decode(mdoc, { allowedTags: [0, 1, 24, 1004] });
114
+ var items = top.get("nameSpaces").get(NS);
115
+ var inner0 = cbor.decode(items[0].value, { allowedTags: [0, 1, 24, 1004] });
116
+ inner0.set("elementValue", "Tampered");
117
+ items[0] = new cbor.Tag(24, cbor.encode(inner0));
118
+ var tampered = cbor.encode(top);
119
+ var e1 = null;
120
+ try { await b.mdoc.verifyIssuerSigned(tampered, { algorithms: ["ES256"] }); } catch (e) { e1 = e; }
121
+ check("verify: tampered element refused (digest-mismatch)", e1 && e1.code === "mdoc/digest-mismatch");
122
+
123
+ // Tamper the COSE signature deterministically (flip a byte in the
124
+ // issuerAuth signature element) → bad-signature.
125
+ var top2 = cbor.decode(mdoc, { allowedTags: [0, 1, 24, 1004] });
126
+ var ia = top2.get("issuerAuth");
127
+ ia[3] = Buffer.from(ia[3]); ia[3][0] ^= 0xff;
128
+ var bad = cbor.encode(top2);
129
+ var e2 = null;
130
+ try { await b.mdoc.verifyIssuerSigned(bad, { algorithms: ["ES256"] }); } catch (e) { e2 = e; }
131
+ check("verify: tampered COSE signature refused", e2 && e2.code === "cose/bad-signature");
132
+
133
+ // A malformed x5chain certificate surfaces a clean error (not raw OpenSSL).
134
+ var top3 = cbor.decode(mdoc, { allowedTags: [0, 1, 24, 1004] });
135
+ top3.get("issuerAuth")[1].set(33, Buffer.from([0x30, 0x03, 0x02, 0x01, 0x01]));
136
+ var e6 = null;
137
+ try { await b.mdoc.verifyIssuerSigned(cbor.encode(top3), { algorithms: ["ES256"] }); } catch (e) { e6 = e; }
138
+ check("verify: malformed x5chain cert refused cleanly", e6 && e6.code === "mdoc/bad-cert");
139
+
140
+ // alg outside allowlist
141
+ var e3 = null;
142
+ try { await b.mdoc.verifyIssuerSigned(mdoc, { algorithms: ["EdDSA"] }); } catch (e) { e3 = e; }
143
+ check("verify: alg outside allowlist refused", e3 && (e3.code === "cose/alg-not-allowed"));
144
+ }
145
+
146
+ async function testValidityAndDocType() {
147
+ var cert = _makeCert();
148
+
149
+ // expired (valid window 10 days ago .. 1 day ago)
150
+ var expired = await _makeMdoc(cert, { validFrom: Date.now() - 86400000 * 10, validUntil: Date.now() - 86400000 });
151
+ var e1 = null;
152
+ try { await b.mdoc.verifyIssuerSigned(expired, { algorithms: ["ES256"] }); } catch (e) { e1 = e; }
153
+ check("verify: expired credential refused", e1 && e1.code === "mdoc/expired");
154
+
155
+ // not yet valid
156
+ var future = await _makeMdoc(cert, { validFrom: Date.now() + 86400000 * 30 });
157
+ var e2 = null;
158
+ try { await b.mdoc.verifyIssuerSigned(future, { algorithms: ["ES256"] }); } catch (e) { e2 = e; }
159
+ check("verify: not-yet-valid credential refused", e2 && e2.code === "mdoc/not-yet-valid");
160
+
161
+ // opts.at within window accepts an otherwise-expired credential
162
+ var ok = await b.mdoc.verifyIssuerSigned(expired, { algorithms: ["ES256"], at: new Date(Date.now() - 86400000 * 2) });
163
+ check("verify: opts.at within window accepts", ok.docType === DOCTYPE);
164
+
165
+ // docType mismatch
166
+ var e3 = null;
167
+ try { await b.mdoc.verifyIssuerSigned(await _makeMdoc(cert), { algorithms: ["ES256"], expectedDocType: "org.iso.18013.5.1.photoID" }); } catch (e) { e3 = e; }
168
+ check("verify: docType mismatch refused", e3 && e3.code === "mdoc/doctype-mismatch");
169
+
170
+ // malformed validUntil (a non-date) fails closed
171
+ var badValidity = await _makeMdoc(cert, { validUntilRaw: new cbor.Tag(0, "not-a-date") });
172
+ var e4 = null;
173
+ try { await b.mdoc.verifyIssuerSigned(badValidity, { algorithms: ["ES256"] }); } catch (e) { e4 = e; }
174
+ check("verify: malformed validUntil refused (fail closed)", e4 && e4.code === "mdoc/bad-validity");
175
+
176
+ // invalid opts.at refused (lesson carried from b.tsa / b.vc)
177
+ var e5 = null;
178
+ try { await b.mdoc.verifyIssuerSigned(await _makeMdoc(cert), { algorithms: ["ES256"], at: new Date("nope") }); } catch (e) { e5 = e; }
179
+ check("verify: invalid opts.at refused", e5 && e5.code === "mdoc/bad-at");
180
+
181
+ // Two signed IssuerSignedItems with the same elementIdentifier (each
182
+ // with a valid MSO digest) is ambiguous → fail closed, not last-wins.
183
+ var dup = await _makeMdoc(cert, { elements: [["family_name", "Doe"], ["family_name", "Roe"]] });
184
+ var e6 = null;
185
+ try { await b.mdoc.verifyIssuerSigned(dup, { algorithms: ["ES256"] }); } catch (e) { e6 = e; }
186
+ check("verify: duplicate elementIdentifier refused", e6 && e6.code === "mdoc/duplicate-element");
187
+ }
188
+
189
+ async function testChainAndInputGuards() {
190
+ var cert = _makeCert();
191
+ var mdoc = await _makeMdoc(cert);
192
+
193
+ // unrelated anchor → untrusted
194
+ var other = _makeCert("Unrelated Root");
195
+ var e1 = null;
196
+ try { await b.mdoc.verifyIssuerSigned(mdoc, { algorithms: ["ES256"], trustAnchorsPem: [other.pem] }); } catch (e) { e1 = e; }
197
+ check("verify: unrelated anchor refused", e1 && e1.code === "mdoc/untrusted-chain");
198
+
199
+ // empty trust-anchor shape refused (no fail-open)
200
+ var e2 = null;
201
+ try { await b.mdoc.verifyIssuerSigned(mdoc, { algorithms: ["ES256"], trustAnchorsPem: [] }); } catch (e) { e2 = e; }
202
+ check("verify: empty trustAnchorsPem refused", e2 && e2.code === "mdoc/bad-trust-anchors");
203
+
204
+ // garbage input → not CBOR / malformed
205
+ var e3 = null;
206
+ try { await b.mdoc.verifyIssuerSigned(Buffer.from([0x00, 0x01]), { algorithms: ["ES256"] }); } catch (e) { e3 = e; }
207
+ check("verify: garbage input refused", e3 && (e3.code === "mdoc/malformed" || e3.code === "mdoc/bad-input" || /cbor/.test(e3.code || "")));
208
+
209
+ // missing algorithms
210
+ var e4 = null;
211
+ try { await b.mdoc.verifyIssuerSigned(mdoc, {}); } catch (e) { e4 = e; }
212
+ check("verify: missing algorithms refused", e4 && e4.code === "mdoc/algorithms-required");
213
+ }
214
+
215
+ async function run() {
216
+ testSurface();
217
+ await testRoundTrip();
218
+ await testDigestAndSignatureRefusals();
219
+ await testValidityAndDocType();
220
+ await testChainAndInputGuards();
221
+ }
222
+
223
+ module.exports = { run: run };
224
+
225
+ if (require.main === module) {
226
+ run().then(
227
+ function () { console.log("[mdoc] OK — " + helpers.getChecks() + " checks passed"); },
228
+ function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
229
+ );
230
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.0.128",
3
+ "version": "0.0.129",
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": {