@blamejs/pki 0.9.5 → 0.9.6

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/lib/trust.js CHANGED
@@ -1035,6 +1035,7 @@ function anchor(entry, opts) {
1035
1035
  !entry.name || !intrinsic.isArray(entry.name.rdns))) {
1036
1036
  throw E("trust/bad-input", "anchor expects a trust-store entry ({ name, publicKey, algorithm, ... })");
1037
1037
  }
1038
+ // allow:caller-option-presence-tested-against-undefined -- absence means no purpose restricts the bundle, so a null must be refused
1038
1039
  if (opts.purpose !== undefined) {
1039
1040
  if (_arrIndexOf(PURPOSES, opts.purpose) === -1) {
1040
1041
  throw E("trust/bad-input", "anchor: opts.purpose must be one of " + _arrJoin(PURPOSES, " | "));
@@ -1226,7 +1227,7 @@ var _BUNDLE_PATHS = [
1226
1227
  ];
1227
1228
 
1228
1229
  var _SYSTEM_ANCHOR_OPTS = intrinsic.assign(intrinsic.create(null), {
1229
- path: 1, allowNodeBundle: 1, maxBytes: 1,
1230
+ path: 1, allowNodeBundle: 1, maxBytes: 1, distrustedKeys: 1,
1230
1231
  });
1231
1232
 
1232
1233
  /** @internal What an operator runs to produce a bundle this verb can read, per platform. Named in
@@ -1306,9 +1307,52 @@ function _readBundleText(chosen, maxBytes) {
1306
1307
  return raw;
1307
1308
  }
1308
1309
 
1310
+ /**
1311
+ * @primitive pki.trust.spkiDigest
1312
+ * @signature pki.trust.spkiDigest(cert) -> string
1313
+ * @since 0.9.6
1314
+ * @status stable
1315
+ * @spec RFC 5280
1316
+ * @related pki.path.validate, pki.trust.systemAnchors, pki.transport.https
1317
+ *
1318
+ * The SHA-256 digest of a certificate's DER SubjectPublicKeyInfo in canonical form, as 64 lower-case
1319
+ * hexadecimal characters. Canonical form is an uncompressed EC point with its named curve and an RSA
1320
+ * key with NULL parameters, which is how nearly every certificate encodes a key, so for those it is
1321
+ * the digest of the bytes the certificate carries and the SPKI SHA-256 root programs publish. This is
1322
+ * the entry `distrustedKeys` takes on `pki.path.validate`, `pki.path.build`,
1323
+ * `pki.trust.systemAnchors`, the TLS policy of `pki.transport.https`, and the verbs that validate a
1324
+ * chain on a caller's behalf. Every certificate that carries the same key has the same digest, however
1325
+ * it encodes the key, so one entry covers a CA certificate and each of its cross-signatures. `cert` is
1326
+ * DER bytes, a PEM string, or a `pki.schema.x509.parse` result. An identifier that restricts a key to
1327
+ * one use names the same key: id-ecDH and id-ecMQV read as ecPublicKey, and RSASSA-PSS and RSAES-OAEP
1328
+ * read as rsaEncryption. A key node does not read, such as a composite or stateful hash-based key, is
1329
+ * digested as the certificate encodes it, and a composite key is its own key, so listing one of its
1330
+ * component keys does not list the composite.
1331
+ *
1332
+ * @example
1333
+ * var pair = await pki.key.generate("Ed25519");
1334
+ * var der = await pki.x509.sign({ subject: "Example CA", subjectPublicKey: await pki.key.export(pair.publicKey),
1335
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
1336
+ * extensions: { basicConstraints: { cA: true }, keyUsage: ["keyCertSign"] } },
1337
+ * { key: await pki.key.export(pair.privateKey) });
1338
+ * pki.trust.spkiDigest(der).length; // 64
1339
+ */
1340
+ function spkiDigest(cert) {
1341
+ if (cert === undefined || cert === null) throw E("trust/bad-input", "spkiDigest takes a certificate (DER, PEM, or a parsed certificate)");
1342
+ var spki;
1343
+ if (typeof cert === "string") spki = x509.parse(x509.pemDecode(cert, "CERTIFICATE")).subjectPublicKeyInfo.bytes;
1344
+ else if (guard.bytes.isByteSource(cert)) spki = x509.parse(guard.bytes.snapshotSource(cert, E, "trust/bad-input", "the certificate")).subjectPublicKeyInfo.bytes;
1345
+ else if (typeof cert === "object" && cert.subjectPublicKeyInfo && guard.bytes.isByteSource(cert.subjectPublicKeyInfo.bytes)) {
1346
+ spki = guard.bytes.snapshotSource(cert.subjectPublicKeyInfo.bytes, E, "trust/bad-input", "the certificate's subjectPublicKeyInfo.bytes");
1347
+ } else {
1348
+ throw E("trust/bad-input", "spkiDigest takes a certificate (DER, PEM, or a parsed certificate)");
1349
+ }
1350
+ return guard.distrust.keyDigest(spki);
1351
+ }
1352
+
1309
1353
  /**
1310
1354
  * @primitive pki.trust.systemAnchors
1311
- * @signature pki.trust.systemAnchors(opts?) -> { anchors, source, path, skipped }
1355
+ * @signature pki.trust.systemAnchors(opts?) -> { anchors, intermediates, source, path, skipped }
1312
1356
  * @since 0.8.15
1313
1357
  * @status stable
1314
1358
  * @spec RFC 7468 (PEM), RFC 5280
@@ -1321,16 +1365,35 @@ function _readBundleText(chosen, maxBytes) {
1321
1365
  * bundle carries that is not a certificate is listed in `skipped` with the label that named it,
1322
1366
  * rather than dropped.
1323
1367
  *
1368
+ * Only a self-issued certificate becomes an anchor. A certificate whose issuer name differs from its
1369
+ * subject name is listed in `skipped` with the reason `trust/not-self-issued` and returned, as DER,
1370
+ * in `intermediates`, which `pki.path.build` takes as `candidates`. A Windows store export includes
1371
+ * the Intermediate Certification Authorities store, which holds certificates the host has seen
1372
+ * rather than ones it trusts, and OpenSSL's default verification also requires a self-issued
1373
+ * certificate at the top of a chain. A certificate whose names cannot be compared, because an
1374
+ * attribute value carries a control byte, is listed in `skipped` with that reason and is returned in
1375
+ * neither list.
1376
+ *
1377
+ * A certificate whose key is listed in `opts.distrustedKeys` is neither an anchor nor an
1378
+ * intermediate; it is listed in `skipped` with the reason `trust/distrusted-key`. A bundle left with
1379
+ * no anchor is refused with `trust/no-anchors`.
1380
+ *
1324
1381
  * A host whose roots are not in a readable PEM file is refused, and the refusal names the command
1325
1382
  * that exports them. macOS keeps its roots in a binary keychain and Windows in a registry-backed
1326
1383
  * store, and reading either means running another program, which this toolkit does not do. Falling
1327
1384
  * back to Node's set instead would report anchors the host does not use, so that takes the
1328
- * explicit `opts.allowNodeBundle`.
1385
+ * explicit `opts.allowNodeBundle`. Node's set is the Mozilla list as of the Node release that
1386
+ * compiled it, so a root that Mozilla has since removed stays in it until Node is upgraded;
1387
+ * `opts.distrustedKeys` removes one sooner.
1329
1388
  *
1330
1389
  * @opts
1331
1390
  * - `path` -- read this file instead of searching the per-platform list.
1332
- * - `allowNodeBundle` -- return Node's compiled-in set when no bundle can be read.
1391
+ * - `allowNodeBundle` -- `true` returns Node's compiled-in set when no bundle can be read.
1392
+ * It takes `true`, `false`, `null` or an absent option, and any other value is refused with
1393
+ * `trust/bad-input`.
1333
1394
  * - `maxBytes` -- cap the bundle read (default `C.LIMITS.TRUST_MAX_BYTES`).
1395
+ * - `distrustedKeys` -- SHA-256 digests of SubjectPublicKeyInfo values (64 hex characters or 32
1396
+ * bytes each, see `pki.trust.spkiDigest`) whose certificates are left out.
1334
1397
  *
1335
1398
  * @example
1336
1399
  * // Read the host's bundle where there is one, and say so when there is not. Check `source`
@@ -1347,9 +1410,12 @@ function systemAnchors(opts) {
1347
1410
  "unknown systemAnchors option ");
1348
1411
  var maxBytes = guard.limits.cap(opts.maxBytes, "systemAnchors: opts.maxBytes",
1349
1412
  LIMITS.TRUST_MAX_BYTES, { E: E, code: "trust/bad-input", min: 1, max: LIMITS.TRUST_MAX_BYTES });
1413
+ var allowNodeBundle = guard.identifier.booleanOption(opts.allowNodeBundle, E, "trust/bad-input",
1414
+ "systemAnchors: opts.allowNodeBundle");
1415
+ var distrusted = guard.distrust.keys(opts.distrustedKeys, E, "trust/bad-input", "systemAnchors: opts.distrustedKeys");
1350
1416
 
1351
1417
  var chosen = null;
1352
- if (opts.path !== undefined) {
1418
+ if (opts.path != null) {
1353
1419
  if (typeof opts.path !== "string" || opts.path.length === 0) {
1354
1420
  throw E("trust/bad-input", "systemAnchors: opts.path must be a non-empty string");
1355
1421
  }
@@ -1364,7 +1430,7 @@ function systemAnchors(opts) {
1364
1430
  * set a validation runs against for a different one. */
1365
1431
  try { text = _readBundleText(chosen, maxBytes); }
1366
1432
  catch (e) {
1367
- if (opts.allowNodeBundle !== true || !e || e.code !== "trust/no-readable-store") throw e;
1433
+ if (!allowNodeBundle || !e || e.code !== "trust/no-readable-store") throw e;
1368
1434
  text = null;
1369
1435
  chosen = null;
1370
1436
  }
@@ -1382,15 +1448,36 @@ function systemAnchors(opts) {
1382
1448
  }
1383
1449
 
1384
1450
  if (text === null) {
1385
- if (opts.allowNodeBundle !== true) {
1451
+ if (!allowNodeBundle) {
1386
1452
  throw E("trust/no-readable-store", "this host keeps no CA bundle this toolkit can read" +
1387
1453
  (chosen === null ? "" : " at " + guard.text.showValue(chosen)) + "; " + _exportHint() +
1388
1454
  ", or pass opts.allowNodeBundle to use the Mozilla set compiled into Node, which is NOT " +
1389
1455
  "what this host trusts");
1390
1456
  }
1391
- return _anchorsFromPems(nodeTls.rootCertificates, "node-bundled", null);
1457
+ return _anchorsFromPems(nodeTls.rootCertificates, "node-bundled", null, distrusted);
1392
1458
  }
1393
- return _anchorsFromBundle(text, chosen);
1459
+ return _anchorsFromBundle(text, chosen, distrusted);
1460
+ }
1461
+
1462
+ /** @internal Why a parsed bundle certificate is not an anchor, or null when it is one. A
1463
+ * distrusted key is reported first, so a listed intermediate is not offered as a candidate either.
1464
+ * The name comparison is the RFC 5280 sec. 7.1 one, and a name it cannot compare (a control byte
1465
+ * in an attribute value) is reported rather than thrown, so one malformed entry does not refuse the
1466
+ * whole bundle. */
1467
+ function _systemEntryRefusal(cert, distrusted) {
1468
+ if (guard.distrust.has(distrusted, cert.subjectPublicKeyInfo.bytes)) {
1469
+ return { intermediate: false, reason: "trust/distrusted-key: the certificate's key is listed in opts.distrustedKeys" };
1470
+ }
1471
+ var selfIssued;
1472
+ try { selfIssued = guard.name.dnEqual(cert.subject.rdns, cert.issuer.rdns, E, "trust/bad-input", "the bundle certificate"); }
1473
+ catch (e) {
1474
+ return { intermediate: false, reason: (e && e.code ? e.code : "trust/bad-input") + ": " + guard.text.describeThrown(e) };
1475
+ }
1476
+ if (!selfIssued) {
1477
+ return { intermediate: true, reason: "trust/not-self-issued: the issuer name differs from the subject name, so the " +
1478
+ "certificate is not a root; it is returned in intermediates for chain building" };
1479
+ }
1480
+ return null;
1394
1481
  }
1395
1482
 
1396
1483
  /** @internal A line that reads as a BEGIN boundary but does not start one, because RFC 7468 sec. 2
@@ -1429,7 +1516,7 @@ function _reportUnusableBoundaries(bytes, rows, skipped) {
1429
1516
  /** @internal Every certificate block becomes an anchor and every other block is reported, because
1430
1517
  * a bundle an operator assembled by hand is exactly where a key or a CRL ends up, and a reader
1431
1518
  * that drops one silently leaves them believing it was read. */
1432
- function _anchorsFromBundle(text, chosen) {
1519
+ function _anchorsFromBundle(text, chosen, distrusted) {
1433
1520
  var rows;
1434
1521
  try { rows = pkix.pemDecodeBundle(text, {}, errors.PemError); }
1435
1522
  catch (e) {
@@ -1440,7 +1527,7 @@ function _anchorsFromBundle(text, chosen) {
1440
1527
  throw E("trust/bad-bundle", "the CA bundle at " + guard.text.showValue(chosen) +
1441
1528
  " is not readable as PEM: " + guard.text.describeThrown(e), e);
1442
1529
  }
1443
- var anchors = [], skipped = [];
1530
+ var anchors = [], skipped = [], intermediates = [];
1444
1531
  _reportUnusableBoundaries(text, rows, skipped);
1445
1532
  for (var i = 0; i < rows.length; i++) {
1446
1533
  var row = rows[i];
@@ -1479,40 +1566,53 @@ function _anchorsFromBundle(text, chosen) {
1479
1566
  guard.list.append(skipped, { index: row.index, offset: row.offset, label: row.label, reason: why });
1480
1567
  continue;
1481
1568
  }
1569
+ var refusal = _systemEntryRefusal(cert, distrusted);
1570
+ if (refusal !== null) {
1571
+ guard.list.append(skipped, { index: row.index, offset: row.offset, label: row.label, reason: refusal.reason });
1572
+ if (refusal.intermediate) guard.list.append(intermediates, intrinsic.bufferFrom(row.der));
1573
+ continue;
1574
+ }
1482
1575
  guard.list.append(anchors, _mkAnchor(cert, {
1483
1576
  distrustAfter: {}, purposes: null, label: null, mozillaCaPolicy: false,
1484
1577
  }));
1485
1578
  }
1486
1579
  if (anchors.length === 0) {
1487
1580
  throw E("trust/no-anchors", "the CA bundle at " + guard.text.showValue(chosen) +
1488
- " carries no certificate, so it names no trust anchor");
1581
+ " carries no self-issued certificate this read admits, so it names no trust anchor");
1489
1582
  }
1490
- return _systemResult(anchors, "ca-bundle", chosen, skipped);
1583
+ return _systemResult(anchors, intermediates, "ca-bundle", chosen, skipped);
1491
1584
  }
1492
1585
 
1493
- function _anchorsFromPems(pems, source, chosen) {
1494
- var anchors = [], skipped = [];
1586
+ function _anchorsFromPems(pems, source, chosen, distrusted) {
1587
+ var anchors = [], skipped = [], intermediates = [];
1495
1588
  for (var i = 0; i < pems.length; i++) {
1496
- var cert = null, why = null;
1497
- try { cert = x509.parse(x509.pemDecode(pems[i], "CERTIFICATE")); }
1589
+ var cert = null, why = null, der = null;
1590
+ try { der = x509.pemDecode(pems[i], "CERTIFICATE"); cert = x509.parse(der); }
1498
1591
  catch (e) { why = (e && e.code ? e.code : "trust/not-a-certificate") + ": " + guard.text.describeThrown(e); }
1499
1592
  if (cert === null) {
1500
1593
  guard.list.append(skipped, { index: i, offset: null, label: "CERTIFICATE", reason: why });
1501
1594
  continue;
1502
1595
  }
1596
+ var refusal = _systemEntryRefusal(cert, distrusted);
1597
+ if (refusal !== null) {
1598
+ guard.list.append(skipped, { index: i, offset: null, label: "CERTIFICATE", reason: refusal.reason });
1599
+ if (refusal.intermediate) guard.list.append(intermediates, intrinsic.bufferFrom(der));
1600
+ continue;
1601
+ }
1503
1602
  guard.list.append(anchors, _mkAnchor(cert, {
1504
1603
  distrustAfter: {}, purposes: null, label: null, mozillaCaPolicy: false,
1505
1604
  }));
1506
1605
  }
1507
- if (anchors.length === 0) throw E("trust/no-anchors", "the " + source + " set carries no certificate");
1508
- return _systemResult(anchors, source, chosen, skipped);
1606
+ if (anchors.length === 0) throw E("trust/no-anchors", "the " + source + " set carries no self-issued certificate this read admits");
1607
+ return _systemResult(anchors, intermediates, source, chosen, skipped);
1509
1608
  }
1510
1609
 
1511
1610
  /** @internal Defined rather than assigned, and a fresh list each call: the caller owns what it is
1512
1611
  * handed, so emptying it must not change what the next call returns. */
1513
- function _systemResult(anchors, source, chosen, skipped) {
1612
+ function _systemResult(anchors, intermediates, source, chosen, skipped) {
1514
1613
  var out = intrinsic.create(null);
1515
1614
  guard.verdict.set(out, "anchors", anchors);
1615
+ guard.verdict.set(out, "intermediates", intermediates);
1516
1616
  guard.verdict.set(out, "source", source);
1517
1617
  guard.verdict.set(out, "path", chosen);
1518
1618
  guard.verdict.set(out, "skipped", skipped);
@@ -1528,4 +1628,5 @@ module.exports = intrinsic.freeze({
1528
1628
  systemAnchors: systemAnchors,
1529
1629
  discoversBundles: discoversBundles,
1530
1630
  anchor: anchor,
1631
+ spkiDigest: spkiDigest,
1531
1632
  });
@@ -1,6 +1,13 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright (c) blamejs contributors
3
3
  "use strict";
4
+ // codebase-patterns:allow-file caller-option-presence-tested-against-undefined
5
+ /** @internal Every member of a trust anchor RESTRICTS what it may certify: a policy set,
6
+ * policy flags, name constraints, a path length. A `null` here is a malformed spec rather
7
+ * than an option left out, because reading it as an omission would drop a restriction the
8
+ * caller named and build a WIDER anchor than they described without saying so. Presence is
9
+ * asked against `undefined` alone, and a null member is refused by the builder's own typed
10
+ * error, which the nested-record vector in schema-trustanchor.test.js pins. */
4
11
  /**
5
12
  * @module pki.trustanchor
6
13
  * @nav Trust
@@ -98,7 +105,15 @@ function _certPathDer(spec) {
98
105
  throw _err("trustanchor/bad-input", "certPath.taName must be a non-empty distinguished name (RFC 5914 sec. 2)");
99
106
  }
100
107
  var kids = [b.raw(nameDer)];
101
- /** @internal The RFC 5914 module is IMPLICIT TAGS, so a context tag REPLACES the member's own
108
+ /** @internal Presence is asked as "not undefined" here, and deliberately not as "not
109
+ * absent", which is how a caller option is read everywhere else. These members RESTRICT
110
+ * what the anchor may certify: a policy set, policy flags, name constraints and a path
111
+ * length. Reading a `null` as an omission would drop the restriction the caller named and
112
+ * build a WIDER anchor than they described, without saying so. A member given as `null`
113
+ * is a malformed spec and is refused by the builder's own typed error, which the
114
+ * nested-record vector in schema-trustanchor.test.js pins.
115
+ *
116
+ * The RFC 5914 module is IMPLICIT TAGS, so a context tag REPLACES the member's own
102
117
  * universal tag rather than wrapping it. `b.implicit` re-tags the value it is given; only the
103
118
  * three members the module writes EXPLICIT beside get a wrapper. */
104
119
  if (spec.certificate !== undefined) {
package/lib/tsp-sign.js CHANGED
@@ -650,6 +650,9 @@ function _buildTsaChains(leaf, pool) {
650
650
  * certificate when `certReq` was false) and an intermediate absent from the
651
651
  * token can still verify and chain.
652
652
  * @opts revocationChecker Passed through to `pki.path.validate`.
653
+ * @opts distrustedKeys SHA-256 digests of SubjectPublicKeyInfo DER (64 hexadecimal characters or
654
+ * 32 bytes each, see `pki.trust.spkiDigest`). A listed key on the TSA chain,
655
+ * the anchor included, fails the token with `tsp/untrusted-tsa`.
653
656
  * @example
654
657
  * var pair = await pki.key.generate("Ed25519");
655
658
  * var signerKeyPkcs8 = await pki.key.export(pair.privateKey);
@@ -664,7 +667,7 @@ function _buildTsaChains(leaf, pool) {
664
667
  * res.valid; // boolean; pass opts.trustAnchors to also chain the TSA cert to a root
665
668
  * res.genTime; // Date, read from the verified eContent
666
669
  */
667
- var _VERIFY_OPTS = intrinsic.assign(intrinsic.create(null), { certs: 1, trustAnchors: 1, nonce: 1, reqPolicy: 1, revocationChecker: 1, allowWeakDigests: 1 });
670
+ var _VERIFY_OPTS = intrinsic.assign(intrinsic.create(null), { certs: 1, trustAnchors: 1, nonce: 1, reqPolicy: 1, revocationChecker: 1, allowWeakDigests: 1, distrustedKeys: 1 });
668
671
 
669
672
  function _anyCritical(extensions) {
670
673
  return guard.list.anyMatches(extensions, function (e) { return !!e && !!e.critical; });
@@ -684,6 +687,8 @@ async function verify(token, data, opts) {
684
687
  if (opts.certs != null && (!Array.isArray(opts.certs) || !guard.list.allMatch(opts.certs, function (c) { return guard.bytes.isByteSource(c); }))) {
685
688
  throw _err("tsp/bad-input", "pki.tsp.verify opts.certs must be an array of DER certificate Buffers");
686
689
  }
690
+ /** @internal Typed here, ahead of the path step, whose throws all become `tsp/untrusted-tsa`. */
691
+ var distrustedKeys = guard.distrust.keys(opts.distrustedKeys, _err, "tsp/bad-input", "pki.tsp.verify opts.distrustedKeys");
687
692
  var tokenDer = _tokenDer(token);
688
693
  var parsed = schemaTsp.parseToken(tokenDer);
689
694
  var tst = parsed.tstInfo;
@@ -732,6 +737,7 @@ async function verify(token, data, opts) {
732
737
  async function validateAt(chain, when) {
733
738
  var res = await pathValidate.validate(chain, {
734
739
  time: when, trustAnchors: opts.trustAnchors, requiredEku: ["timeStamping"], checkPurpose: "timeStamping", revocationChecker: opts.revocationChecker,
740
+ distrustedKeys: distrustedKeys,
735
741
  });
736
742
  if (_moreEstablished(res.revocationChecked, revocationChecked)) revocationChecked = res.revocationChecked;
737
743
  if (anchorConstraints == null && res.anchorConstraints != null) anchorConstraints = res.anchorConstraints;
@@ -122,7 +122,7 @@ var CERT_SCOPED_STATUS = intrinsic.freeze(intrinsic.assign(intrinsic.create(null
122
122
 
123
123
  var _BLOB_OPTS = intrinsic.assign(intrinsic.create(null), {
124
124
  rootCertificates: 1, time: 1, previousNo: 1, requireRollbackCheck: 1, allowStale: 1,
125
- statusPolicy: 1, rejectUnknownStatus: 1,
125
+ statusPolicy: 1, rejectUnknownStatus: 1, distrustedKeys: 1,
126
126
  });
127
127
 
128
128
  function _isPlainObject(v) { return !!v && typeof v === "object" && !intrinsic.isArray(v); }
@@ -218,8 +218,9 @@ function _verifyMetadataBlob(blob, opts) {
218
218
  if (!intrinsic.isArray(roots) || roots.length === 0) {
219
219
  throw _err("webauthn/metadata-no-root", "verifying a metadata BLOB requires opts.rootCertificates -- the FIDO root(s) to anchor it to; this library bundles none");
220
220
  }
221
- if (o.time !== undefined) guard.time.assertValid(o.time, _err, "webauthn/bad-input", "opts.time");
222
- var at = o.time === undefined ? new intrinsic.Date() : o.time;
221
+ if (o.time != null) guard.time.assertValid(o.time, _err, "webauthn/bad-input", "opts.time");
222
+ var at = o.time == null ? new intrinsic.Date() : o.time;
223
+ // allow:caller-option-presence-tested-against-undefined -- absence means no rollback comparison is made, so a null must be refused rather than drop the check
223
224
  if (o.previousNo !== undefined && (!intrinsic.isSafeInteger(o.previousNo) || o.previousNo < 0)) {
224
225
  throw _err("webauthn/bad-input", "opts.previousNo must be a non-negative safe integer");
225
226
  }
@@ -228,13 +229,18 @@ function _verifyMetadataBlob(blob, opts) {
228
229
  throw _err("webauthn/bad-input", "opts." + k + " must be a boolean");
229
230
  }
230
231
  });
232
+ // allow:caller-option-presence-tested-against-undefined -- absence means any status is accepted, so a null must be refused rather than widen the policy
231
233
  if (o.statusPolicy !== undefined && typeof o.statusPolicy !== "function" &&
232
234
  o.statusPolicy !== "any" && o.statusPolicy !== "latest-by-date") {
233
235
  throw _err("webauthn/bad-input", "opts.statusPolicy must be \"any\", \"latest-by-date\", or a function");
234
236
  }
237
+ // allow:caller-option-presence-tested-against-undefined -- absence means no rollback comparison is made, so a null must be refused rather than drop the check
235
238
  if (o.requireRollbackCheck === true && o.previousNo === undefined) {
236
239
  throw _err("webauthn/metadata-no-baseline", "opts.requireRollbackCheck was set without opts.previousNo, so there is no baseline to compare against");
237
240
  }
241
+ /** @internal Typed here, before the chain walk, because the walk turns a validation rejection into
242
+ * an untrusted verdict, and a malformed list must be reported as the caller's input fault. */
243
+ var distrusted = guard.distrust.keys(o.distrustedKeys, _err, "webauthn/bad-input", "opts.distrustedKeys");
238
244
  opts = o;
239
245
  var anchors = intrinsic.mapList(roots, function (r, i) { return _asCert(r, "opts.rootCertificates[" + i + "]"); });
240
246
 
@@ -351,14 +357,17 @@ function _verifyMetadataBlob(blob, opts) {
351
357
  }, function (e) { throw _err("webauthn/unsupported-algorithm", "the metadata BLOB x5c leaf key could not be imported for " + header.alg, e); });
352
358
  var anchored = intrinsic.chain(verified, function (ok) {
353
359
  if (!ok) throw _err("webauthn/verify-failed", "the metadata BLOB signature does not verify under its x5c leaf key");
354
- return _chainToAnchor(chain, anchors, at);
360
+ return _chainToAnchor(chain, anchors, at, undefined, undefined, distrusted);
355
361
  });
356
362
  return intrinsic.chain(anchored, function () {
357
363
  return _parsePayload(segs[1], at, opts);
358
364
  });
359
365
  }
360
366
 
361
- function _chainToAnchor(chain, anchors, at, what, code) {
367
+ /** @internal `distrusted` is a list `guard.distrust.keys` returned, or null. It reaches every
368
+ * validation below, and the self-presented anchor, which is trusted without one, is refused here
369
+ * when its key is listed. */
370
+ function _chainToAnchor(chain, anchors, at, what, code, distrusted) {
362
371
  var subject = what || "metadata BLOB certificate chain";
363
372
  var faultCode = code || "webauthn/metadata-untrusted";
364
373
  var ordered = _reverse(intrinsic.copyList(chain));
@@ -381,12 +390,19 @@ function _chainToAnchor(chain, anchors, at, what, code) {
381
390
  var path = intrinsic.copyList(ordered);
382
391
  var strippedAnchor = false;
383
392
  if (path.length && _isAnchorItself(path[0], anchor)) { path = intrinsic.copyList(path, 1); strippedAnchor = true; }
384
- if (path.length === 0) return strippedAnchor;
393
+ if (path.length === 0) {
394
+ if (strippedAnchor && guard.distrust.has(distrusted, anchor.subjectPublicKeyInfo.bytes)) {
395
+ lastFault = _err(faultCode, "the " + subject + " is a trust anchor whose key is listed in opts.distrustedKeys");
396
+ return false;
397
+ }
398
+ return strippedAnchor;
399
+ }
385
400
  var verdict = pathValidate.validate(path, {
386
401
  time: at,
387
402
  trustAnchors: { name: anchor.subject, publicKey: anchor.subjectPublicKeyInfo.bytes,
388
403
  algorithm: anchor.subjectPublicKeyInfo.algorithm.oid,
389
404
  parameters: anchor.subjectPublicKeyInfo.algorithm.parameters },
405
+ distrustedKeys: distrusted || null,
390
406
  });
391
407
  return intrinsic.chain(verdict, function (r) { return !!(r && r.valid); },
392
408
  function (e) { lastFault = e; return false; });
@@ -443,6 +459,7 @@ function _parsePayload(seg, at, opts) {
443
459
  if (!_isPlainObject(payload)) throw _err("webauthn/bad-metadata-blob", "the metadata BLOB payload must be a JSON object");
444
460
  if (typeof payload.legalHeader !== "string") throw _err("webauthn/bad-metadata-blob", "the metadata BLOB payload must carry a string legalHeader");
445
461
  if (!intrinsic.isSafeInteger(payload.no) || payload.no < 0) throw _err("webauthn/bad-metadata-blob", "the metadata BLOB payload must carry a non-negative integer no");
462
+ // allow:caller-option-presence-tested-against-undefined -- absence means no rollback comparison is made, so a null must be refused rather than drop the check
446
463
  if (opts.previousNo !== undefined && payload.no <= opts.previousNo) {
447
464
  throw _err("webauthn/metadata-rollback", "the metadata BLOB no " + payload.no + " does not exceed the previously held " + opts.previousNo);
448
465
  }
@@ -525,7 +542,9 @@ function _parsePayload(seg, at, opts) {
525
542
  });
526
543
  var result = { no: payload.no, legalHeader: payload.legalHeader, nextUpdate: payload.nextUpdate,
527
544
  stale: stale, allowStale: opts.allowStale === true,
545
+ // allow:caller-option-presence-tested-against-undefined -- absence means no rollback comparison is made, so a null must be refused rather than drop the check
528
546
  rollbackChecked: opts.previousNo !== undefined,
547
+ // allow:caller-option-presence-tested-against-undefined -- absence means no rollback comparison is made, so a null must be refused rather than drop the check
529
548
  previousNo: opts.previousNo === undefined ? null : opts.previousNo,
530
549
  entries: entries, byAaguid: byAaguid, byKeyIdentifier: byKeyIdentifier,
531
550
  statusPolicy: opts.statusPolicy || "any", rejectUnknownStatus: opts.rejectUnknownStatus === true };
@@ -558,7 +577,8 @@ function metadataAnchors(entry, opts) {
558
577
  if (typeof opts !== "object" || intrinsic.isArray(opts)) throw _err("webauthn/bad-input", "metadataAnchors opts must be an object");
559
578
  guard.identifier.assertKnownKeys(opts, _ANCHOR_OPTS, _err, "webauthn/bad-input", "metadataAnchors opts has an unknown key ");
560
579
  opts = guard.identifier.ownOptions(opts);
561
- if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
580
+ if (opts.time != null) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
581
+ // allow:caller-option-presence-tested-against-undefined -- a trust input: absence means no metadata anchor is applied, so a null must be refused
562
582
  if (opts.metadata !== undefined) {
563
583
  if (!isVerifiedResult(opts.metadata)) {
564
584
  throw _err("webauthn/bad-input", "metadataAnchors opts.metadata expects a verifyMetadataBlob result -- an object that merely resembles one has not been through the signature and chain checks");
@@ -571,7 +591,8 @@ function metadataAnchors(entry, opts) {
571
591
  * freshness and every status report's effective date are compared against, so a replacement
572
592
  * answering with an earlier one accepts expired metadata and suppresses a report already in
573
593
  * effect. */
574
- var at = opts.time === undefined ? new intrinsic.Date() : opts.time;
594
+ var at = opts.time == null ? new intrinsic.Date() : opts.time;
595
+ // allow:caller-option-presence-tested-against-undefined -- a trust input: absence means no metadata anchor is applied, so a null must be refused
575
596
  if (opts.metadata !== undefined) assertFresh(opts.metadata, at, "metadataAnchors");
576
597
  if (statusDenied(entry, opts.metadata, opts.certificate, at)) {
577
598
  throw _err("webauthn/metadata-status", "the metadata entry for this authenticator carries a disqualifying status report, so it registers no anchors to trust");