@blamejs/pki 0.9.4 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +7 -6
  3. package/bin/pki.js +110 -34
  4. package/lib/acme.js +91 -46
  5. package/lib/attrcert-sign.js +4 -3
  6. package/lib/cmp-build.js +16 -6
  7. package/lib/cmp-session.js +19 -3
  8. package/lib/cmp-verify.js +25 -2
  9. package/lib/cms-compress.js +3 -3
  10. package/lib/cms-digest.js +10 -5
  11. package/lib/cms-encrypt.js +6 -1
  12. package/lib/cms-sign.js +16 -0
  13. package/lib/cms-verify.js +32 -14
  14. package/lib/constants.js +1 -0
  15. package/lib/crl-sign.js +3 -3
  16. package/lib/csr-sign.js +6 -2
  17. package/lib/ct.js +16 -3
  18. package/lib/est.js +67 -31
  19. package/lib/guard-all.js +2 -0
  20. package/lib/guard-distrust.js +224 -0
  21. package/lib/guard-identifier.js +27 -1
  22. package/lib/guard-json.js +1 -1
  23. package/lib/http-transport.js +299 -36
  24. package/lib/jose.js +1 -1
  25. package/lib/lint.js +67 -7
  26. package/lib/ocsp-verify.js +9 -3
  27. package/lib/ocsp.js +16 -4
  28. package/lib/oid.js +4 -0
  29. package/lib/path-validate.js +119 -18
  30. package/lib/pkcs12-build.js +6 -1
  31. package/lib/pki-build.js +38 -6
  32. package/lib/possession.js +6 -2
  33. package/lib/related-cert.js +2 -2
  34. package/lib/revocation-fetch.js +46 -11
  35. package/lib/scep.js +39 -14
  36. package/lib/schema-pkix.js +3 -3
  37. package/lib/sigstore.js +11 -6
  38. package/lib/smime.js +32 -16
  39. package/lib/tls-cert-compress.js +6 -5
  40. package/lib/trust.js +121 -20
  41. package/lib/trustanchor-build.js +16 -1
  42. package/lib/tsp-sign.js +15 -2
  43. package/lib/webauthn-mds.js +29 -8
  44. package/lib/webauthn.js +46 -14
  45. package/lib/x509-sign.js +26 -10
  46. package/package.json +2 -2
  47. package/sbom.cdx.json +6 -6
@@ -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");
package/lib/webauthn.js CHANGED
@@ -1,6 +1,14 @@
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 The `expected*` and `require*` options are SECURITY EXPECTATIONS, so presence
6
+ * is asked against `undefined` alone and a `null` is not read as an omission. A caller whose
7
+ * `expectedChallenge` arrives null, from an expired session or a missing field, must be
8
+ * refused rather than have the ceremony verified with the challenge comparison skipped:
9
+ * read as absence the check does not run, the verdict reports `checked.challenge` false, and
10
+ * the call still succeeds. `expectedTopOrigin: null` is a stated value on top of that, naming
11
+ * an unframed ceremony (WebAuthn sec. 5.8.1). */
4
12
  /**
5
13
  * @module pki.webauthn
6
14
  * @nav Validation
@@ -413,7 +421,7 @@ function _aaguidIsSigned(fmt) { return _AAGUID_SIGNED_BY_FMT[fmt] !== false; }
413
421
  var _VERIFY_OPTS = intrinsic.assign(intrinsic.create(null), {
414
422
  time: 1, metadata: 1, tpmPolicy: 1, safetyNetRoots: 1, verifySafetyNetJws: 1, requireCtsProfileMatch: 1,
415
423
  expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
416
- rootCertificates: 1,
424
+ rootCertificates: 1, distrustedKeys: 1,
417
425
  clientDataJSON: 1, expectedChallenge: 1, expectedOrigin: 1, expectedTopOrigin: 1,
418
426
  });
419
427
 
@@ -484,12 +492,12 @@ function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
484
492
  throw _err("webauthn/anchor-not-applicable",
485
493
  "opts.rootCertificates was supplied, but this attestation carries no trust path to anchor (format '" + res.fmt + "')");
486
494
  }
487
- var at = vopts.time !== undefined ? vopts.time : new intrinsic.Date();
495
+ var at = vopts.time != null ? vopts.time : new intrinsic.Date();
488
496
  var anchoredAll = intrinsic.reduce(paths, function (p, info) {
489
497
  return intrinsic.chain(p, function () {
490
- var pathAt = vopts.time !== undefined ? vopts.time : (info.at || at);
498
+ var pathAt = vopts.time != null ? vopts.time : (info.at || at);
491
499
  return mds.chainToAnchor(_reverse(intrinsic.copyList(info.tp)), roots, pathAt,
492
- "attestation trust path (against the roots supplied as opts.rootCertificates)");
500
+ "attestation trust path (against the roots supplied as opts.rootCertificates)", undefined, vopts.distrustedKeys);
493
501
  });
494
502
  }, _promiseResolve(_Promise, undefined));
495
503
  return intrinsic.chain(anchoredAll, function () {
@@ -559,6 +567,7 @@ function _applyBindings(authData, coseKey, opts) {
559
567
  "signature made by this credential would use SHA-1, so it is refused unless opts.allowedAlgorithms " +
560
568
  "names -65535 explicitly");
561
569
  }
570
+ // allow:caller-option-presence-tested-against-undefined -- absence means every supported algorithm is accepted, so a null must be refused rather than drop the allowlist
562
571
  if (opts.allowedAlgorithms !== undefined) {
563
572
  /** @internal The entries are required to be the list's OWN. A hole answers from the prototype at
564
573
  * that index, and the universal test skips it, so a sparse list with a number installed on
@@ -720,7 +729,7 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
720
729
  if (!intrinsic.isArray(roots) || roots.length === 0) {
721
730
  throw _err("webauthn/safetynet-no-root", "verifying an android-safetynet attestation requires opts.safetyNetRoots -- the Google root(s) to anchor the x5c chain to; this library bundles none (WebAuthn 8.5)");
722
731
  }
723
- if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
732
+ if (opts.time != null) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
724
733
 
725
734
  _requireAttShape(att.attStmt, ["ver", "response"], ["ver", "response"]);
726
735
  _attRead(att.attStmt, "ver", cbor.read.textString, "a text string");
@@ -783,10 +792,10 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
783
792
  var jwsTrusted = intrinsic.chain(_verifySig(-257, sigBytes, leaf.subjectPublicKeyInfo.bytes,
784
793
  intrinsic.bufferFrom(segs[0] + "." + segs[1], "ascii"), _err), function (ok) {
785
794
  if (!ok) throw _err("webauthn/verify-failed", "the android-safetynet JWS signature does not verify under the x5c leaf key");
786
- chainAt = opts.time !== undefined ? opts.time
795
+ chainAt = opts.time != null ? opts.time
787
796
  : (typeof payload.timestampMs === "number" && intrinsic.isFinite(payload.timestampMs) && payload.timestampMs > 0
788
797
  ? new intrinsic.Date(payload.timestampMs) : undefined);
789
- return _safetyNetChainTrusted(chain, roots, chainAt);
798
+ return _safetyNetChainTrusted(chain, roots, chainAt, opts.distrustedKeys);
790
799
  });
791
800
  return intrinsic.chain(jwsTrusted, function () {
792
801
  var res = guard.verdict.of(_result("android-safetynet", "Basic", chain, att), { safetyNet: signals });
@@ -879,7 +888,7 @@ function _safetyNetHostnameOk(leaf) {
879
888
  });
880
889
  }
881
890
 
882
- function _safetyNetChainTrusted(chain, roots, time) {
891
+ function _safetyNetChainTrusted(chain, roots, time, distrusted) {
883
892
  var anchors;
884
893
  try {
885
894
  anchors = intrinsic.mapList(roots, function (root, i) {
@@ -892,7 +901,7 @@ function _safetyNetChainTrusted(chain, roots, time) {
892
901
  });
893
902
  } catch (e) { return _promiseReject(_Promise, e); }
894
903
  return mds.chainToAnchor(chain, anchors, time === undefined ? new intrinsic.Date() : time,
895
- "android-safetynet x5c certificate chain", "webauthn/safetynet-cert-untrusted");
904
+ "android-safetynet x5c certificate chain", "webauthn/safetynet-cert-untrusted", distrusted);
896
905
  }
897
906
 
898
907
  function _result(fmt, attestationType, chain, att) {
@@ -1015,6 +1024,9 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
1015
1024
  * allowedAlgorithms -- the COSE algorithms this relying party accepts
1016
1025
  * metadata -- a verifyMetadataBlob result; the model's own roots govern
1017
1026
  * rootCertificates -- trust anchors you pin, for the models no catalogue lists
1027
+ * distrustedKeys -- SHA-256 digests of SubjectPublicKeyInfo DER (64 hex characters
1028
+ * or 32 bytes); a listed key on a trust path refuses that path on
1029
+ * the metadata, rootCertificates and safetyNetRoots routes
1018
1030
  * time -- the instant certificate validity is judged at
1019
1031
  * tpmPolicy -- required TPM key properties; refuses a non-TPM attestation
1020
1032
  * safetyNetRoots / verifySafetyNetJws / requireCtsProfileMatch -- the
@@ -1045,11 +1057,16 @@ function verify(attestationObject, clientDataHash, opts) {
1045
1057
  /** @internal A caller who passes no options gets a record with NO prototype. Defaulted to `{}` it
1046
1058
  * inherits, so a property installed on `Object.prototype` reads as an option this call never set. */
1047
1059
  opts = opts || intrinsic.create(null);
1060
+ var distrusted;
1048
1061
  try {
1049
1062
  if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
1050
1063
  guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
1051
1064
  opts = guard.identifier.ownOptions(opts);
1052
- if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
1065
+ if (opts.time != null) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
1066
+ /** @internal Typed here, before any chain is walked, because the walk turns a validation
1067
+ * rejection into an untrusted verdict, and a malformed list must be reported as the caller's
1068
+ * input fault on every route. */
1069
+ distrusted = guard.distrust.keys(opts.distrustedKeys, _err, "webauthn/bad-input", "opts.distrustedKeys");
1053
1070
  /** @internal The walk comes from the capture. Each pass of it is a REFUSAL of a mistyped option,
1054
1071
  * and a mistyped one that survives is read for truthiness later where the rule wants exactly
1055
1072
  * `true`, so a walk that visits nothing admits `"true"` as the opt-in. */
@@ -1061,7 +1078,7 @@ function verify(attestationObject, clientDataHash, opts) {
1061
1078
  var attBytes, cdh, clientData = null;
1062
1079
  try {
1063
1080
  attBytes = _bytesArg(attestationObject, "attestationObject");
1064
- var haveJson = opts.clientDataJSON !== undefined, haveHash = clientDataHash !== undefined;
1081
+ var haveJson = opts.clientDataJSON != null, haveHash = clientDataHash !== undefined;
1065
1082
  if (haveJson === haveHash) {
1066
1083
  throw _err("webauthn/bad-input", "verify takes exactly one of clientDataJSON or the clientDataHash argument");
1067
1084
  }
@@ -1075,6 +1092,9 @@ function verify(attestationObject, clientDataHash, opts) {
1075
1092
  });
1076
1093
  cdh = _sha("sha256", cdjBytes);
1077
1094
  } else {
1095
+ /** @internal `expectedTopOrigin: null` states that the ceremony must be unframed, so
1096
+ * it is an expectation the caller supplied and counts as supplied here too. */
1097
+ // allow:caller-option-presence-tested-against-undefined
1078
1098
  if (opts.expectedChallenge !== undefined || opts.expectedOrigin !== undefined ||
1079
1099
  opts.expectedTopOrigin !== undefined) {
1080
1100
  throw _err("webauthn/bad-input",
@@ -1093,6 +1113,7 @@ function verify(attestationObject, clientDataHash, opts) {
1093
1113
  }
1094
1114
  var verifier = VERIFIERS[att.fmt];
1095
1115
  if (!verifier) return _promiseReject(_Promise, _err("webauthn/unsupported-format", "attestation statement format '" + att.fmt + "' is not supported"));
1116
+ // allow:caller-option-presence-tested-against-undefined -- absence means no TPM policy is applied, so a null must be refused rather than drop it
1096
1117
  if (opts.tpmPolicy !== undefined && !_formatCarries(att, "tpm")) {
1097
1118
  return _promiseReject(_Promise, _err("webauthn/tpm-policy", "opts.tpmPolicy requires a TPM attestation, but this attestation is format '" + att.fmt + "', which carries no TPM public area"));
1098
1119
  }
@@ -1105,6 +1126,7 @@ function verify(attestationObject, clientDataHash, opts) {
1105
1126
  vopts = guard.identifier.ownOptions(opts);
1106
1127
  vopts.rootCertificates = _snapshotRoots(opts.rootCertificates);
1107
1128
  vopts.safetyNetRoots = _snapshotRoots(opts.safetyNetRoots);
1129
+ vopts.distrustedKeys = distrusted;
1108
1130
  } catch (e) { return _promiseReject(_Promise, e); }
1109
1131
  var ranVerifier = intrinsic.chain(_promiseResolve(_Promise, undefined),
1110
1132
  function () { return verifier(att, clientDataHash, vopts); });
@@ -1112,7 +1134,9 @@ function verify(attestationObject, clientDataHash, opts) {
1112
1134
  return guard.verdict.of(res, { clientData: clientData });
1113
1135
  });
1114
1136
  var anchoredRes = intrinsic.chain(withClientData, function (res) {
1137
+ // allow:caller-option-presence-tested-against-undefined -- a trust input: absence falls back to signature-only verification, so a null must be refused rather than skip anchoring
1115
1138
  if (vopts.metadata === undefined) {
1139
+ // allow:caller-option-presence-tested-against-undefined -- a trust input: absence falls back to signature-only verification, so a null must be refused rather than skip anchoring
1116
1140
  if (vopts.rootCertificates !== undefined) return _applyCallerRoots(res, vopts.rootCertificates, vopts);
1117
1141
  var anchored = guard.verdict.of(res, { anchoredTo: _anchoredRoutes(res, null) });
1118
1142
  var sn = _safetyNetAnchored(anchored);
@@ -1127,6 +1151,7 @@ function verify(attestationObject, clientDataHash, opts) {
1127
1151
  function () { return _applyMetadata(res, att, vopts); });
1128
1152
  return intrinsic.chain(applyMd,
1129
1153
  function (out) { return guard.verdict.of(out, { anchoredTo: _anchoredRoutes(out, "metadata") }); }, function (e) {
1154
+ // allow:caller-option-presence-tested-against-undefined -- a trust input: absence falls back to signature-only verification, so a null must be refused rather than skip anchoring
1130
1155
  if (!e || e.code !== "webauthn/metadata-not-found" || vopts.rootCertificates === undefined) throw e;
1131
1156
  return intrinsic.chain(_promiseResolve(_Promise, _applyCallerRoots(res, vopts.rootCertificates, vopts, e.missedPaths)),
1132
1157
  function (withRoots) {
@@ -1186,7 +1211,7 @@ function _applyMetadata(res, att, opts) {
1186
1211
  guard.list.append(applied, { entry: entry, anchors: anchors, identifier: identifier });
1187
1212
  return { anchors: anchors, identifier: identifier };
1188
1213
  }
1189
- var at = opts.time !== undefined ? opts.time : new intrinsic.Date();
1214
+ var at = opts.time != null ? opts.time : new intrinsic.Date();
1190
1215
  mds.assertFresh(md, at, "the metadata supplied as opts.metadata");
1191
1216
  var governed = [];
1192
1217
  var missed = null;
@@ -1207,9 +1232,9 @@ function _applyMetadata(res, att, opts) {
1207
1232
  var governedAnchored = intrinsic.reduce(governed, function (p, item) {
1208
1233
  return intrinsic.chain(p, function () {
1209
1234
  var g = item.g, info = item.info;
1210
- var pathAt = opts.time !== undefined ? opts.time : (info.at || at);
1235
+ var pathAt = opts.time != null ? opts.time : (info.at || at);
1211
1236
  return mds.chainToAnchor(_reverse(intrinsic.copyList(info.tp)), g.anchors, pathAt,
1212
- "attestation trust path for " + g.identifier + " (against the roots its metadata entry registers)");
1237
+ "attestation trust path for " + g.identifier + " (against the roots its metadata entry registers)", undefined, opts.distrustedKeys);
1213
1238
  });
1214
1239
  }, _promiseResolve(_Promise, undefined));
1215
1240
  return intrinsic.chain(governedAnchored,
@@ -1272,6 +1297,9 @@ void constants;
1272
1297
  * @opts
1273
1298
  * - `rootCertificates` -- REQUIRED. The trust anchors the BLOB's own signing chain
1274
1299
  * must reach. A certificate, PEM, or DER bytes.
1300
+ * - `distrustedKeys` -- SHA-256 digests of SubjectPublicKeyInfo DER (64 hex characters
1301
+ * or 32 bytes). A listed key anywhere on the BLOB's signing chain, the root
1302
+ * included, refuses the BLOB as `webauthn/metadata-untrusted`.
1275
1303
  * - `time` -- the instant freshness is judged at. Defaults to now.
1276
1304
  * - `previousNo` -- the sequence number of the BLOB you already hold. A BLOB whose
1277
1305
  * `no` is not greater is refused as a rollback.
@@ -1507,6 +1535,10 @@ function parseClientData(bytes, opts) {
1507
1535
  }
1508
1536
  checked.origin = true;
1509
1537
  }
1538
+ /** @internal `null` is a VALUE here, not absence: it states that the ceremony must be
1539
+ * unframed, which the branch below checks. So presence is asked the one way that keeps
1540
+ * it, and an option left out is the only form that skips the check. */
1541
+ // allow:caller-option-presence-tested-against-undefined -- see above
1510
1542
  if (opts.expectedTopOrigin !== undefined) {
1511
1543
  var wantTop = opts.expectedTopOrigin;
1512
1544
  var framed = doc.crossOrigin === true;
package/lib/x509-sign.js CHANGED
@@ -228,6 +228,10 @@ function _buildExtensions(extSpec, ctx) {
228
228
  return "unknown extension " + JSON.stringify(k) + " in the extensions spec; pass a pre-encoded Extension DER via the array form for a custom extension";
229
229
  });
230
230
 
231
+ /** @internal The criticality flags, typed at this door and read from the record below: the
232
+ * emitters took them off the caller's spec, where a value that was never a boolean selected a
233
+ * criticality nobody asked for. */
234
+ var crit = _b.criticality(extSpec);
231
235
  var bc = extSpec.basicConstraints;
232
236
  if (bc != null) _validateBcSpec(bc);
233
237
  var caTrue = !!(bc && bc.cA === true);
@@ -257,14 +261,14 @@ function _buildExtensions(extSpec, ctx) {
257
261
  var akiF = _akiFields(akiVal, ctx);
258
262
  _push(out,_ext(O("authorityKeyIdentifier"), false, _extAki(akiF[0], akiF[1], akiF[2])));
259
263
  }
260
- if (ku != null) _push(out,_ext(O("keyUsage"), extSpec.keyUsageCritical !== false, _extKeyUsage(ku)));
261
- if (extSpec.extendedKeyUsage != null) _push(out,_ext(O("extKeyUsage"), !!extSpec.extendedKeyUsageCritical, _extExtKeyUsage(extSpec.extendedKeyUsage)));
264
+ if (ku != null) _push(out,_ext(O("keyUsage"), crit.keyUsageCritical, _extKeyUsage(ku)));
265
+ if (extSpec.extendedKeyUsage != null) _push(out,_ext(O("extKeyUsage"), crit.extendedKeyUsageCritical, _extExtKeyUsage(extSpec.extendedKeyUsage)));
262
266
  if (bc != null) {
263
267
  if (bc.cA === true && bc.critical === false) throw _err("x509/bad-input", "a CA certificate's basicConstraints MUST be critical (RFC 5280 sec. 4.2.1.9)");
264
268
  _push(out,_ext(O("basicConstraints"), bc.critical !== false, _extBasicConstraints(bc)));
265
269
  }
266
270
  if (extSpec.subjectAltName != null) _push(out,_ext(O("subjectAltName"), ctx.subjectEmpty, _extSan(extSpec.subjectAltName)));
267
- if (extSpec.certificatePolicies != null) _push(out,_ext(O("certificatePolicies"), !!extSpec.certificatePoliciesCritical, _extCertPolicies(extSpec.certificatePolicies)));
271
+ if (extSpec.certificatePolicies != null) _push(out,_ext(O("certificatePolicies"), crit.certificatePoliciesCritical, _extCertPolicies(extSpec.certificatePolicies)));
268
272
  /** @internal Sections 4.2.2.1 and 4.2.1.15 fix these non-critical; sec. 4.2.1.13 states a SHOULD
269
273
  * and the toolkit issues the conforming form. */
270
274
  if (extSpec.authorityInfoAccess != null) guard.list.append(out, _ext(O("authorityInfoAccess"), false, _extAia(extSpec.authorityInfoAccess)));
@@ -279,15 +283,15 @@ function _buildExtensions(extSpec, ctx) {
279
283
  * does not process the extension, so a critical one is a certificate this toolkit's own validator
280
284
  * must reject and pki.lint.certificate grades `unknown-critical-extension`. The default is the
281
285
  * usable form and the knob reaches the SHOULD-conforming one, as certificatePolicies does. */
282
- if (extSpec.policyMappings != null) guard.list.append(out, _ext(O("policyMappings"), !!extSpec.policyMappingsCritical, _extPolicyMappings(extSpec.policyMappings)));
286
+ if (extSpec.policyMappings != null) guard.list.append(out, _ext(O("policyMappings"), crit.policyMappingsCritical, _extPolicyMappings(extSpec.policyMappings)));
283
287
  if (extSpec.issuerAltName != null) guard.list.append(out, _ext(O("issuerAltName"), false, _encodeGeneralNames(_reqDenseArray(extSpec.issuerAltName, "issuerAltName"))));
284
288
  /** @internal pki.path.validate does not process qcStatements, so a critical one is rejected as an
285
289
  * unrecognized critical extension; the default is the form that validates and the knob reaches
286
290
  * the other, as policyMappings and certificatePolicies do. */
287
- if (extSpec.qcStatements != null) guard.list.append(out, _ext(O("qcStatements"), !!extSpec.qcStatementsCritical, _extQcStatements(extSpec.qcStatements)));
291
+ if (extSpec.qcStatements != null) guard.list.append(out, _ext(O("qcStatements"), crit.qcStatementsCritical, _extQcStatements(extSpec.qcStatements)));
288
292
  /** @internal RFC 7633 sec. 4: SHOULD NOT be critical, and the clause names the case where critical
289
293
  * is the desired behavior, so the knob exists and the default is the compatible form. */
290
- if (extSpec.tlsFeature != null) guard.list.append(out, _ext(O("tlsFeature"), !!extSpec.tlsFeatureCritical, _b.extTlsFeature(extSpec.tlsFeature)));
294
+ if (extSpec.tlsFeature != null) guard.list.append(out, _ext(O("tlsFeature"), crit.tlsFeatureCritical, _b.extTlsFeature(extSpec.tlsFeature)));
291
295
  if (extSpec.nameConstraints != null) {
292
296
  if (!caTrue) throw _err("x509/bad-input", "nameConstraints appears only in a CA certificate, so it requires basicConstraints cA=TRUE (RFC 5280 sec. 4.2.1.10)");
293
297
  guard.list.append(out, _ext(O("nameConstraints"), true, _extNameConstraints(extSpec.nameConstraints)));
@@ -565,6 +569,14 @@ function _hasCriticalSan(extSpec) {
565
569
  * (positive, <= 20 octets), the validity UTCTime/GeneralizedTime cutover, the DER DEFAULT omissions
566
570
  * (v1 tag, `critical=FALSE`, `cA=FALSE`), and the CA cross-field rules (keyCertSign and
567
571
  * pathLenConstraint require cA=TRUE) are all enforced; a violation throws a typed `CertificateError`.
572
+ *
573
+ * Criticality is stated per extension: `keyUsageCritical` (default true), and
574
+ * `extendedKeyUsageCritical`, `certificatePoliciesCritical`, `policyMappingsCritical`,
575
+ * `qcStatementsCritical` and `tlsFeatureCritical` (each default false). `basicConstraints` carries
576
+ * its own `critical` inside the value. Each is a boolean: a value other than `true`, `false`,
577
+ * `null` or an absent flag is refused `x509/bad-input`. The extensions RFC 5280 fixes one way
578
+ * (`nameConstraints` critical, `authorityInfoAccess` and `cRLDistributionPoints` non-critical) take
579
+ * no flag.
568
580
  * Where the spec carries raw DER (a `Name` Buffer, a pre-encoded `Extension`, an issuer `publicKey`
569
581
  * SPKI), a structural fault throws `CertificateError`, while a malformed leaf inside those bytes
570
582
  * throws `Asn1Error`, the same two-error contract the parsers present.
@@ -959,14 +971,18 @@ function _extensionRow(name) {
959
971
  if (oid.isDottedDecimal(name)) return intrinsic.hasOwn(EXTENSION_BY_OID, name) ? EXTENSION_BY_OID[name] : null;
960
972
  throw _err("x509/bad-input", "unknown extension " + guard.text.showValue(name) + "; the names spec.extensions takes are accepted, and any other extension is named by its dotted OID with its extnValue pre-encoded");
961
973
  }
974
+ /** @internal `stated` is the caller's option, carried here from the door that typed it,
975
+ * so absence has the same two forms there and here: a `null` states no criticality and
976
+ * takes the row's default, the way an omitted option does. Read as stated, it was
977
+ * returned AS the criticality and reached the emitter as a non-boolean. */
962
978
  function _extensionCritical(row, opts) {
963
979
  var rule = row.rule, stated = opts.critical;
964
980
  if (rule.inValue) {
965
- if (stated !== undefined) throw _err("x509/bad-input", row.name + " carries its criticality in the value ({ cA, pathLen, critical }), so opts.critical does not apply");
981
+ if (stated != null) throw _err("x509/bad-input", row.name + " carries its criticality in the value ({ cA, pathLen, critical }), so opts.critical does not apply");
966
982
  return null;
967
983
  }
968
- if (rule.knob) return stated === undefined ? rule.dflt : stated;
969
- if (stated !== undefined && stated !== rule.fixed) {
984
+ if (rule.knob) return stated == null ? rule.dflt : stated;
985
+ if (stated != null && stated !== rule.fixed) {
970
986
  throw _err("x509/bad-input", row.name + " is emitted " + (rule.fixed ? "critical" : "non-critical") + " (" + rule.cite + "), so opts.critical cannot say otherwise");
971
987
  }
972
988
  return rule.fixed;
@@ -1025,7 +1041,7 @@ function extension(name, value, opts) {
1025
1041
  function _extension(name, value, opts) {
1026
1042
  opts = guard.identifier.optionsObject(opts, _err, "x509/bad-input", "pki.x509.extension options");
1027
1043
  guard.identifier.assertKnownKeys(opts, KNOWN_EXTENSION_OPTS, _err, "x509/bad-input", "pki.x509.extension has an unknown option ");
1028
- if (opts.critical !== undefined && typeof opts.critical !== "boolean") throw _err("x509/bad-input", "opts.critical must be a boolean");
1044
+ if (opts.critical != null && typeof opts.critical !== "boolean") throw _err("x509/bad-input", "opts.critical must be a boolean");
1029
1045
  if (value === undefined || value === null) throw _err("x509/bad-input", "the extension value is required");
1030
1046
  var row = _extensionRow(name);
1031
1047
  if (row === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -87,6 +87,6 @@
87
87
  "devDependencies": {
88
88
  "c8": "12.0.0",
89
89
  "esbuild": "0.28.2",
90
- "eslint": "10.11.0"
90
+ "eslint": "10.12.0"
91
91
  }
92
92
  }
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:9f1767af-18a9-4a06-85fd-6273c947d9b9",
5
+ "serialNumber": "urn:uuid:114691ca-67be-44e1-9f29-b49116312f56",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-10-12T04:46:50.282Z",
8
+ "timestamp": "2026-10-21T08:20:31.429Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.9.4",
22
+ "bom-ref": "@blamejs/pki@0.9.6",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.9.4",
25
+ "version": "0.9.6",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.9.4",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.9.6",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.9.4",
57
+ "ref": "@blamejs/pki@0.9.6",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]