@blamejs/pki 0.5.1 → 0.5.3

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/jose.js CHANGED
@@ -566,8 +566,47 @@ async function thumbprint(jwk) {
566
566
  return b64uEncode(digest);
567
567
  }
568
568
 
569
+ /**
570
+ * @primitive pki.jose.sigAlgs
571
+ * @signature pki.jose.sigAlgs() -> Array<{alg,kty,crv,hash,saltLength}>
572
+ * @since 0.5.3
573
+ * @status stable
574
+ * @spec RFC 7518 sec. 3, RFC 8037, RFC 9964
575
+ * @related pki.jose.verify
576
+ *
577
+ * The JWS signature algorithms this toolkit verifies, one row per `alg`, each
578
+ * naming the JWK key type it requires (`kty`, plus the exact `crv` where the
579
+ * curve is fixed), the hash, and the RSASSA-PSS salt length where the algorithm
580
+ * is PSS. Rows describe key material in RFC 7517 / 7518 vocabulary only, so a
581
+ * caller can decide whether a key it holds can sign or verify a given `alg`
582
+ * without a table of its own.
583
+ *
584
+ * MAC algorithms are deliberately absent: `HS*` is not a signature algorithm and
585
+ * listing it beside `RS256` is how the HMAC key-confusion class starts. `none`
586
+ * does not exist here at all.
587
+ *
588
+ * Each call returns a fresh array of fresh rows -- the registry that drives
589
+ * verification is never handed out, so nothing a caller does to the result can
590
+ * widen what a signature check accepts.
591
+ *
592
+ * @example
593
+ * var pss = pki.jose.sigAlgs().filter(function (r) { return r.saltLength; });
594
+ * pss.map(function (r) { return r.alg; }); // -> ["PS256", "PS384", "PS512"]
595
+ */
596
+ function sigAlgs() {
597
+ return Object.keys(SIG_ALGS).map(function (alg) {
598
+ var row = SIG_ALGS[alg];
599
+ var out = { alg: alg, kty: row.kty };
600
+ if (row.crv) out.crv = row.crv;
601
+ if (row.hash) out.hash = row.hash;
602
+ if (row.saltLength) out.saltLength = row.saltLength;
603
+ return out;
604
+ });
605
+ }
606
+
569
607
  module.exports = {
570
608
  base64url: { encode: b64uEncode, decode: b64uDecode },
609
+ sigAlgs: sigAlgs,
571
610
  parseJson: parseJson,
572
611
  verify: verify,
573
612
  sign: sign,
@@ -41,6 +41,7 @@ var ocsp = require("./schema-ocsp");
41
41
  var ocspVerify = require("./ocsp-verify");
42
42
  var crlVerify = require("./crl-verify");
43
43
  var cmpVerify = require("./cmp-verify");
44
+ var cmsVerify = require("./cms-verify");
44
45
  var cmpSession = require("./cmp-session");
45
46
  var guard = require("./guard-all");
46
47
  var constants = require("./constants");
@@ -1187,40 +1188,9 @@ async function validate(path, opts) {
1187
1188
  // opts.requiredEku -- the key purposes the TARGET certificate must be good
1188
1189
  // for, each a registered OID name or a dotted OID string. Resolved (and
1189
1190
  // typo-checked) here at the entry point.
1190
- var requiredEku = null;
1191
- if (opts.requiredEku !== undefined) {
1192
- if (!Array.isArray(opts.requiredEku) || opts.requiredEku.length === 0) {
1193
- throw E("path/bad-input", "validate: opts.requiredEku must be a non-empty array of key-purpose OID names or dotted OID strings");
1194
- }
1195
- requiredEku = opts.requiredEku.map(function (p) {
1196
- if (typeof p !== "string" || p.length === 0) throw E("path/bad-input", "validate: opts.requiredEku entries must be non-empty strings");
1197
- // A dotted-form attempt (leads with a digit) must be a canonical OID -- a
1198
- // loose regex accepted a leading-zero / out-of-bounds key that would never
1199
- // match the canonical EKU the target advertises; anything else is a name.
1200
- if (/^[0-9]/.test(p)) return guard.identifier.assertCanonicalOid(p, E, "path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p));
1201
- var dotted = oid.byName(p);
1202
- if (typeof dotted !== "string") throw E("path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p) + " is not a registered OID name");
1203
- return dotted;
1204
- });
1205
- }
1206
- // opts.checkPurpose -- the single key purpose the ANCHOR's NSS trust metadata
1207
- // (distrustAfter / purposes) is consulted for. Independent of requiredEku
1208
- // (which gates the leaf's own EKU extension): this selects the per-purpose
1209
- // key in the trust-anchor constraint contract. A purpose OID name (or a
1210
- // canonical dotted OID normalized to its name); a bad value throws here.
1211
- var checkPurpose = null;
1212
- if (opts.checkPurpose !== undefined) {
1213
- if (typeof opts.checkPurpose !== "string" || opts.checkPurpose.length === 0) {
1214
- throw E("path/bad-input", "validate: opts.checkPurpose must be a key-purpose OID name or dotted OID string");
1215
- }
1216
- if (/^[0-9]/.test(opts.checkPurpose)) {
1217
- var cpDotted = guard.identifier.assertCanonicalOid(opts.checkPurpose, E, "path/bad-input", "validate: opts.checkPurpose");
1218
- checkPurpose = oid.name(cpDotted) || cpDotted; // normalize a dotted purpose OID to its name for the anchor map
1219
- } else {
1220
- if (typeof oid.byName(opts.checkPurpose) !== "string") throw E("path/bad-input", "validate: opts.checkPurpose " + JSON.stringify(opts.checkPurpose) + " is not a registered OID name");
1221
- checkPurpose = opts.checkPurpose;
1222
- }
1223
- }
1191
+ var purposeOpts = resolvePurposeOpts(opts);
1192
+ var requiredEku = purposeOpts.requiredEku;
1193
+ var checkPurpose = purposeOpts.checkPurpose;
1224
1194
 
1225
1195
  var state = initialize(certs, opts, seeds);
1226
1196
  state._n = n;
@@ -1385,9 +1355,8 @@ async function validate(path, opts) {
1385
1355
  // NaN time) would make `notBefore > it` NaN-false and SILENTLY drop the distrust
1386
1356
  // restriction -- the NaN-Date fail-open. Validate a present date fail-closed
1387
1357
  // before the comparison; an absent (undefined/null) date is no restriction.
1388
- var distrustDate = (checkPurpose && ta.distrustAfter) ? ta.distrustAfter[checkPurpose] : null;
1358
+ var distrustDate = assertAnchorConstraints(ta, checkPurpose);
1389
1359
  if (distrustDate != null) {
1390
- distrustDate = guard.time.assertValid(distrustDate, E, "path/bad-input", "trustAnchor.distrustAfter." + checkPurpose);
1391
1360
  // STRICTLY > : a leaf whose notBefore == the distrust date stays trusted
1392
1361
  // (Mozilla certverifier isDistrustedCertificateChain: endEntityNotBefore
1393
1362
  // <= distrustAfterTime -> not distrusted; the end-of-day ...235959Z
@@ -2211,6 +2180,16 @@ var ocspCore = ocspVerify.makeOcspVerify({
2211
2180
  cmpVerify.setEngine({ verifyWithSpki: _verifyWithSpki, build: build, validate: validate });
2212
2181
  // pki.cmp.session validates the ISSUED leaf certificate (its signature + chain) through the same engine.
2213
2182
  cmpSession.setEngine({ build: build, validate: validate, toAnchor: toAnchor, coerceCert: coerceCert });
2183
+ // pki.cms.verify chains a SignedData's signer certificate to the anchors the CALLER named, so its
2184
+ // `trusted` is decided by this one path engine rather than a second, weaker walk of its own.
2185
+ // `toAnchor` so cms.verify can validate the caller's anchors ONCE at entry, before any signer is
2186
+ // walked -- otherwise a message whose signers all failed would never reach a build call and a
2187
+ // malformed anchor would pass unnoticed.
2188
+ // `resolvePurposeOpts` so cms.verify can reject a malformed requiredEku / checkPurpose at ITS entry
2189
+ // point, through the SAME definition the walk uses -- a message whose signers all failed never
2190
+ // reaches a build call, and a caller's configuration must not be judged by the message's quality.
2191
+ cmsVerify.setEngine({ build: build, validate: validate, toAnchor: toAnchor,
2192
+ resolvePurposeOpts: resolvePurposeOpts, assertAnchorConstraints: assertAnchorConstraints });
2214
2193
 
2215
2194
  /**
2216
2195
  * @primitive pki.path.ocspChecker
@@ -2440,6 +2419,63 @@ function coerceCert(input) {
2440
2419
  // tuple. The algorithm is the SPKI KEY-algorithm OID (the sec. 6.1.4(f)
2441
2420
  // parameter-inheritance value), mirroring trust.js _mkAnchor -- NOT the
2442
2421
  // signature OID. The anchor is an input to validate, never one of the path certs.
2422
+ // The two key-purpose options, resolved and typo-checked. Extracted so a CALLER can validate them
2423
+ // at ITS entry point rather than only when a path is actually walked: a format verb that skips the
2424
+ // walk -- pki.cms.verify does when no signer verified -- would otherwise accept a malformed
2425
+ // purpose in silence, making configuration validity depend on the message. One definition, so the
2426
+ // answer cannot drift between the caller's early check and the walk's own.
2427
+ //
2428
+ // `requiredEku` gates the TARGET certificate's own EKU extension; `checkPurpose` selects which
2429
+ // per-purpose key the ANCHOR's NSS trust metadata (purposes / distrustAfter) is consulted under.
2430
+ // They are independent, and each is a registered OID name or a canonical dotted OID.
2431
+ // An anchor's CONSTRAINT metadata for one purpose, validated fail-closed and returned normalized.
2432
+ // A PRESENT-but-malformed distrustAfter (an Invalid Date: instanceof Date yet a NaN time) would
2433
+ // make `notBefore > it` NaN-false and SILENTLY drop the distrust restriction -- the NaN-Date
2434
+ // fail-open. Absent metadata is no restriction and returns null.
2435
+ //
2436
+ // Separate from resolvePurposeOpts because it validates the ANCHOR rather than the options, and
2437
+ // exposed for the same reason: a caller that may never reach the walk -- pki.cms.verify when no
2438
+ // signer verified -- has to be able to reject a malformed anchor at ITS entry point, through this
2439
+ // same definition, so configuration validity never depends on the message.
2440
+ function assertAnchorConstraints(ta, checkPurpose) {
2441
+ var d = (checkPurpose && ta && ta.distrustAfter) ? ta.distrustAfter[checkPurpose] : null;
2442
+ if (d == null) return null;
2443
+ return guard.time.assertValid(d, E, "path/bad-input", "trustAnchor.distrustAfter." + checkPurpose);
2444
+ }
2445
+
2446
+ function resolvePurposeOpts(opts) {
2447
+ var requiredEku = null;
2448
+ if (opts.requiredEku !== undefined) {
2449
+ if (!Array.isArray(opts.requiredEku) || opts.requiredEku.length === 0) {
2450
+ throw E("path/bad-input", "validate: opts.requiredEku must be a non-empty array of key-purpose OID names or dotted OID strings");
2451
+ }
2452
+ requiredEku = opts.requiredEku.map(function (p) {
2453
+ if (typeof p !== "string" || p.length === 0) throw E("path/bad-input", "validate: opts.requiredEku entries must be non-empty strings");
2454
+ // A dotted-form attempt (leads with a digit) must be a canonical OID -- a
2455
+ // loose regex accepted a leading-zero / out-of-bounds key that would never
2456
+ // match the canonical EKU the target advertises; anything else is a name.
2457
+ if (/^[0-9]/.test(p)) return guard.identifier.assertCanonicalOid(p, E, "path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p));
2458
+ var dotted = oid.byName(p);
2459
+ if (typeof dotted !== "string") throw E("path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p) + " is not a registered OID name");
2460
+ return dotted;
2461
+ });
2462
+ }
2463
+ var checkPurpose = null;
2464
+ if (opts.checkPurpose !== undefined) {
2465
+ if (typeof opts.checkPurpose !== "string" || opts.checkPurpose.length === 0) {
2466
+ throw E("path/bad-input", "validate: opts.checkPurpose must be a key-purpose OID name or dotted OID string");
2467
+ }
2468
+ if (/^[0-9]/.test(opts.checkPurpose)) {
2469
+ var cpDotted = guard.identifier.assertCanonicalOid(opts.checkPurpose, E, "path/bad-input", "validate: opts.checkPurpose");
2470
+ checkPurpose = oid.name(cpDotted) || cpDotted; // normalize a dotted purpose OID to its name for the anchor map
2471
+ } else {
2472
+ if (typeof oid.byName(opts.checkPurpose) !== "string") throw E("path/bad-input", "validate: opts.checkPurpose " + JSON.stringify(opts.checkPurpose) + " is not a registered OID name");
2473
+ checkPurpose = opts.checkPurpose;
2474
+ }
2475
+ }
2476
+ return { requiredEku: requiredEku, checkPurpose: checkPurpose };
2477
+ }
2478
+
2443
2479
  function toAnchor(entry) {
2444
2480
  if (entry && typeof entry === "object" && !Buffer.isBuffer(entry) && entry.name && entry.publicKey && entry.algorithm) {
2445
2481
  // A ready anchor tuple: validate the shape build + validate consume -- name.rdns
@@ -67,23 +67,61 @@ function _pssAlgId(digestName) {
67
67
  var params = b.sequence([b.explicit(0, hashAlg), b.explicit(1, mgf), b.explicit(2, b.integer(BigInt(PSS_SALT[HASH[digestName]])))]);
68
68
  return b.sequence([b.oid(O("rsassaPss")), params]);
69
69
  }
70
- // An id-RSASSA-PSS SPKI MAY pin its permitted hash in the params (RFC 4055 sec. 1.2 / 3.1). Read
71
- // it so signing honors the restriction; absent params or an unrecognized hash returns null.
70
+ // The hash an id-RSASSA-PSS SPKI restricts its key to, or null when it restricts none.
71
+ //
72
+ // RFC 4055 sec. 3.1 draws the line at whether the parameters are THERE: "if present, the parameters
73
+ // field MUST contain RSASSA-PSS-params", and "if RSASSA-PSS-params is present, the certificate user
74
+ // MUST perform those operations using the one-way hash function ... identified in the ...
75
+ // parameters". Absent parameters therefore restrict nothing, and null says so.
76
+ //
77
+ // PRESENT parameters are a restriction even where they look empty. `hashAlgorithm` is
78
+ // `[0] HashAlgorithm DEFAULT sha1Identifier`, so a params SEQUENCE that omits it names SHA-1 --
79
+ // it does not decline to name anything. Reading the omission as "no restriction" is the fail-open
80
+ // that matters here: it turns a key its own certificate confines to SHA-1 into one that will verify
81
+ // a SHA-512 signature. And parameters that are present but unreadable are a restriction this code
82
+ // cannot honor, which is not the same as no restriction either, so they are refused.
72
83
  function _pssHashFromSpki(cert, E) {
73
84
  var params = cert.subjectPublicKeyInfo.algorithm.parameters;
74
85
  if (params == null) return null;
75
- var node = asn1.decode(params);
76
- if (node.tagClass !== "universal" || node.tagNumber !== asn1.TAGS.SEQUENCE || !node.children) return null;
86
+ var node;
87
+ try { node = asn1.decode(params); }
88
+ catch (e) { throw E("unsupported-algorithm", "the id-RSASSA-PSS key parameters are not decodable, so the restriction they carry cannot be honored", e); }
89
+ if (node.tagClass !== "universal" || node.tagNumber !== asn1.TAGS.SEQUENCE || !node.children) {
90
+ throw E("unsupported-algorithm", "the id-RSASSA-PSS key parameters are not an RSASSA-PSS-params SEQUENCE (RFC 4055 sec. 3.1)");
91
+ }
77
92
  var hashField = node.children.filter(function (c) { return c.tagClass === "context" && c.tagNumber === 0; })[0];
78
- if (!hashField || !hashField.children || !hashField.children[0] || !hashField.children[0].children) return null;
93
+ // DEFAULT sha1Identifier -- an omitted hashAlgorithm names SHA-1, which this toolkit does not
94
+ // sign or verify with, so it is reported as the pin it is and refused by the caller's own table.
95
+ if (!hashField) return "sha1";
96
+ if (!hashField.children || !hashField.children[0] || !hashField.children[0].children) {
97
+ throw E("unsupported-algorithm", "the id-RSASSA-PSS key parameters carry a malformed hashAlgorithm");
98
+ }
79
99
  var oidNode = hashField.children[0].children[0];
80
- if (!oidNode || oidNode.tagClass !== "universal" || oidNode.tagNumber !== asn1.TAGS.OBJECT_IDENTIFIER) return null;
100
+ if (!oidNode || oidNode.tagClass !== "universal" || oidNode.tagNumber !== asn1.TAGS.OBJECT_IDENTIFIER) {
101
+ throw E("unsupported-algorithm", "the id-RSASSA-PSS key parameters hashAlgorithm is not an OBJECT IDENTIFIER");
102
+ }
81
103
  var pinnedOid = asn1.read.oid(oidNode);
82
104
  var name = HASH_NAME_BY_OID[pinnedOid];
83
105
  if (!name) throw E("unsupported-algorithm", "the id-RSASSA-PSS signer key pins an unsupported hash algorithm (" + pinnedOid + ")");
84
106
  return name;
85
107
  }
86
108
 
109
+ // @internal -- the WebCrypto hash name an id-RSASSA-PSS SPKI pins, or null when it pins none.
110
+ // Verifiers need the same restriction the signer above honors: a key whose certificate says
111
+ // SHA-256 must not be handed a SHA-512 signature to check, and reading the pin in two places is
112
+ // how the two directions come to disagree. Throws through the caller's E on a hash this toolkit
113
+ // does not implement, so an unreadable restriction is never treated as no restriction.
114
+ function pssSpkiPinnedHash(cert, E) {
115
+ var d = _pssHashFromSpki(cert, E);
116
+ if (!d) return null;
117
+ // A pin this toolkit has no WebCrypto hash for -- SHA-1, which RSASSA-PSS-params names by DEFAULT
118
+ // -- is still a pin. Returning undefined here would hand the caller a falsy value it reads as
119
+ // "unrestricted", which is the same fail-open the DEFAULT reading above exists to close, one
120
+ // layer up.
121
+ if (!HASH[d]) throw E("unsupported-algorithm", "the id-RSASSA-PSS key is restricted to " + d + ", which this toolkit does not verify with");
122
+ return HASH[d];
123
+ }
124
+
87
125
  // resolveSignScheme(cert, so, noSignedAttrs, E) -> the signature scheme from the signer cert's
88
126
  // public-key algorithm + per-signer opts (so.digestAlgorithm / so.pss / so.combinedRsaSig -- the
89
127
  // last folds the digest into a combined RSA signature OID for a caller with no digestAlgorithm
@@ -205,6 +243,7 @@ function signOverTbs(scheme, key, signedBytes, E) {
205
243
  // no drift between the two.
206
244
  module.exports = {
207
245
  resolveSignScheme: resolveSignScheme,
246
+ pssSpkiPinnedHash: pssSpkiPinnedHash,
208
247
  signOverTbs: signOverTbs,
209
248
  MLDSA_SUITABLE_DIGEST: MLDSA_SUITABLE_DIGEST,
210
249
  SLHDSA_BY_OID: SLHDSA_BY_OID,
package/lib/smime.js CHANGED
@@ -567,7 +567,7 @@ function _capped(msg) {
567
567
 
568
568
  /**
569
569
  * @primitive pki.smime.verify
570
- * @signature pki.smime.verify(message, opts?) -> Promise<{ valid, signers, form, content, micalg, protectedHeaders, headerProtection }>
570
+ * @signature pki.smime.verify(message, opts?) -> Promise<{ valid, trusted, signers, form, content, micalg, protectedHeaders, headerProtection }>
571
571
  * @since 0.2.25
572
572
  * @status stable
573
573
  * @spec RFC 8551, RFC 5652, RFC 9788
@@ -577,9 +577,19 @@ function _capped(msg) {
577
577
  * `application/pkcs7-mime; smime-type=signed-data`. For `multipart/signed` the detached CMS signature
578
578
  * is recomputed over the first part's RFC 8551 sec. 3.1.1 canonical form (the SAME canonicalizer the
579
579
  * signer used); for `application/pkcs7-mime` the base64 body is the attached CMS SignedData. Returns
580
- * `pki.cms.verify`'s `{ valid, signers }` verdict PLUS `form`, the recovered `content` (the signed MIME
581
- * entity bytes), and the `micalg`. Like `cms.verify`, this returns the cryptographic verdict only --
582
- * chaining a signer certificate to a trust anchor is the caller's `pki.path.validate` step. A `micalg`
580
+ * `pki.cms.verify`'s `{ valid, trusted, signers }` verdict PLUS `form`, the recovered `content` (the
581
+ * signed MIME entity bytes), and the `micalg`.
582
+ *
583
+ * `valid` and `trusted` are separate claims, exactly as in `cms.verify`: a SignedData carries its own
584
+ * certificates, so `valid` says the signature is sound under one of them and nothing about who signed.
585
+ * Name the roots you accept in `opts.trustAnchors` and `trusted` says every signer chained to one --
586
+ * validated for EMAIL, at both ends of the chain. The signer certificate must carry the
587
+ * `emailProtection` key purpose (RFC 8551 sec. 4.4.4), because a certificate restricted to `serverAuth`
588
+ * chains to its root perfectly well and is still the wrong key to have signed a message; and the anchor's
589
+ * own trust metadata must permit that purpose, because a root distributed with NSS trust bits can be
590
+ * marked untrusted for email while remaining a good TLS root. Override either with `opts.requiredEku`
591
+ * and `opts.checkPurpose`. Supply no anchors and `trusted` is `false` -- there was nothing to chain to.
592
+ * A `micalg`
583
593
  * that disagrees with the actual digest is advisory unless `opts.strictMicalg` (then `smime/micalg-mismatch`).
584
594
  * If the message is header-protected (RFC 9788), `protectedHeaders` is the AUTHENTICATED inner header set (a
585
595
  * tampered outer header cannot alter it) and `headerProtection` is `{ present, mode, fromMismatch, confidential, legacy }`
@@ -596,6 +606,11 @@ function _capped(msg) {
596
606
  * `protectedHeaders` cannot mistake the opt-in heuristic for authenticated headers.
597
607
  *
598
608
  * @opts certs extra signer certificates (DER `Buffer`s) to match, forwarded to `cms.verify`.
609
+ * @opts trustAnchors the roots you accept, forwarded to `cms.verify`; supplying them is what makes
610
+ * `trusted` answerable. Certificate DER or anchor tuples.
611
+ * @opts time the instant the signer's chain is judged at (default now). Only read with `trustAnchors`.
612
+ * @opts requiredEku key purposes the SIGNER certificate must carry. Defaults to `["emailProtection"]`.
613
+ * @opts checkPurpose the purpose the ANCHOR's own trust metadata must permit. Defaults to `"emailProtection"`.
599
614
  * @opts strictMicalg reject a `multipart/signed` whose `micalg` disagrees with the SignerInfo digest.
600
615
  * @opts legacyHeaderProtection opt in to detecting a LEGACY RFC 8551 header-protected message (RFC 9788 sec. 4.10): a Cryptographic Payload that is a bare `message/rfc822` wrap with no `hp=` parameter. When set, a precisely-identified legacy message surfaces the inner message's headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }` -- `headers` an ordered `[{ name, value }]` array (retaining legally-repeated fields such as `Received`), the mode inferred from the envelope (`clear` here) -- NOT under `protectedHeaders`, and `present` stays `false`. Consuming `headerProtection.legacy.headers` is an explicit choice: a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, so this is a heuristic (RFC 9788 sec. 4.10.2: "not based on any strong end-to-end guarantees") -- cross-check `legacy.fromMismatch`. Anything not precisely identified (a nested crypto layer, an `hp=` on the inner message, a non-`message/rfc822` payload, a duplicate of a singleton field, or a duplicate Content-Type) reports `legacy: null`. Off by default. The signed-and-encrypted form (RFC 9788 Appendix C.3.17) is a documented gap (`legacy: null` at `decrypt`; surfaces as `clear` only via the caller's re-`verify` step) -- the non-recursive layered API exposes no single seam holding both the inner signature verdict and the outer header section.
601
616
  * @example
@@ -611,8 +626,28 @@ async function verify(message, opts) {
611
626
  opts = opts || {};
612
627
  var ent = mime.parse(message, SmimeError, "smime/bad-mime");
613
628
  var ct = ent.contentType;
629
+ // Forwarded, not re-decided here. This verb documents itself as pki.cms.verify's verdict plus the
630
+ // MIME surface, so the trust seam that verb offers has to reach it: building the options from
631
+ // scratch and passing only `certs` would leave a caller naming trust anchors with no way to have
632
+ // them applied, and a `trusted` that read false for want of ever being asked.
614
633
  var vOpts = {};
615
634
  if (opts.certs) vOpts.certs = opts.certs;
635
+ if (opts.trustAnchors != null) {
636
+ vOpts.trustAnchors = opts.trustAnchors;
637
+ // Trusted FOR THIS PURPOSE. A chain alone does not make a signer right for email: a
638
+ // certificate restricted to serverAuth chains to its root perfectly well and is still the
639
+ // wrong key to have signed a message. RFC 8551 sec. 4.4.4 names emailProtection as the purpose
640
+ // an S/MIME signer's certificate must carry, so this verb asks for it rather than accepting the
641
+ // purpose-neutral answer. A caller who means something else says so with `requiredEku`.
642
+ vOpts.requiredEku = opts.requiredEku != null ? opts.requiredEku : ["emailProtection"];
643
+ // Both ends of the chain. The EKU above constrains the LEAF; this selects the anchor's own
644
+ // trust metadata, which pki.path consults only when a purpose is named. A root distributed
645
+ // with NSS trust bits can be marked untrusted for email while remaining a good TLS root, so
646
+ // asking only the leaf would let a root explicitly distrusted for email still answer
647
+ // "trusted" for an email message.
648
+ vOpts.checkPurpose = opts.checkPurpose != null ? opts.checkPurpose : "emailProtection";
649
+ }
650
+ if (opts.time !== undefined) vOpts.time = opts.time;
616
651
  if (_isPkcs7(ct.type, "mime")) {
617
652
  if (ct.params["smime-type"] && ct.params["smime-type"] !== "signed-data") throw _err("smime/unsupported-type", "unsupported smime-type " + JSON.stringify(ct.params["smime-type"]) + " (only signed-data)");
618
653
  var p7m = _decodeCms(ent);
@@ -620,7 +655,7 @@ async function verify(message, opts) {
620
655
  var inner;
621
656
  try { inner = _toBuf(schemaCms.parse(p7m).encapContentInfo.eContent); }
622
657
  catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime SignedData has no encapsulated content", e); }
623
- return Object.assign({ valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid, opts.legacyHeaderProtection === true));
658
+ return Object.assign({ valid: res.valid, trusted: res.trusted, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid, opts.legacyHeaderProtection === true));
624
659
  }
625
660
  if (ct.type === "multipart/signed") {
626
661
  if (ct.params.protocol && !_isPkcs7(ct.params.protocol, "signature")) throw _err("smime/bad-multipart", "multipart/signed protocol must be application/pkcs7-signature");
@@ -647,7 +682,7 @@ async function verify(message, opts) {
647
682
  if (opts.strictMicalg && micalg && _micalgSet(micalg) !== (_micalgOf(p7s) || "")) {
648
683
  throw _err("smime/micalg-mismatch", "the multipart/signed micalg " + JSON.stringify(micalg) + " disagrees with the SignerInfo digests");
649
684
  }
650
- return Object.assign({ valid: res2.valid, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg }, _hpSurface(parts[0], ent, "clear", res2.valid, opts.legacyHeaderProtection === true));
685
+ return Object.assign({ valid: res2.valid, trusted: res2.trusted, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg }, _hpSurface(parts[0], ent, "clear", res2.valid, opts.legacyHeaderProtection === true));
651
686
  }
652
687
  throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
653
688
  }
package/lib/tsp-sign.js CHANGED
@@ -604,9 +604,19 @@ function _buildTsaChains(leaf, pool) {
604
604
  * res.valid; // boolean; pass opts.trustAnchor to also chain the TSA cert to a root
605
605
  * res.genTime; // Date, read from the verified eContent
606
606
  */
607
+ // Every option pki.tsp.verify reads. Adding one here is the only way to make it accepted.
608
+ var _VERIFY_OPTS = { certs: 1, trustAnchor: 1, nonce: 1, reqPolicy: 1, revocationChecker: 1 };
609
+
607
610
  async function verify(token, data, opts) {
608
611
  opts = opts || {};
609
612
  if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("tsp/bad-input", "pki.tsp.verify options must be an object");
613
+ // An unrecognized option is refused rather than ignored. This verb spells its anchor option
614
+ // SINGULAR -- `trustAnchor`, an anchor tuple -- while pki.cms.verify and pki.cmp.verify spell it
615
+ // `trustAnchors` and take certificate DER. A caller carrying the plural spelling here would
616
+ // otherwise get no anchoring and no error: the TSA certificate unchained, `valid: true`, and
617
+ // nothing to notice it by. Naming the difference at the boundary is the only place it is cheap.
618
+ guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "tsp/bad-input",
619
+ "pki.tsp.verify has an unknown option (note the anchor option here is `trustAnchor`, singular, an anchor tuple -- not the `trustAnchors` certificate list pki.cms.verify takes) ");
610
620
  if (opts.certs != null && (!Array.isArray(opts.certs) || !opts.certs.every(function (c) { return Buffer.isBuffer(c) || c instanceof Uint8Array; }))) {
611
621
  throw _err("tsp/bad-input", "pki.tsp.verify opts.certs must be an array of DER certificate Buffers"); // a bad element is a caller error, never silently dropped
612
622
  }
@@ -57,11 +57,27 @@ var OKP_CRV = { 6: { oid: "Ed25519", len: 32 }, 7: { oid: "Ed448", len: 57 } };
57
57
  // -8 (EdDSA) is Ed25519 ONLY, and the RFC 9864 fully-specified ids (-9 ESP256, -51 ESP384,
58
58
  // -52 ESP512, -19 Ed25519, -53 Ed448) each pin key type + curve. A verifier accepts the
59
59
  // fully-specified ids even though WebAuthn recommends against them for credential creation.
60
+ // The RSA credential-key bounds. 2048 bits is the floor every current FIDO authenticator and
61
+ // NIST SP 800-57 agree on; nothing in the field emits less, so the floor refuses forgeable keys
62
+ // without refusing real ones. The exponent bound is a work bound, not a security one.
63
+ var RSA_MIN_MODULUS_BITS = 2048;
64
+ var RSA_MAX_EXPONENT_BYTES = 8;
65
+ // The modulus BIT length. A byte count is not one: minimally encoded, a 256-byte modulus whose
66
+ // leading byte is 0x01 is 2041 bits, and would clear a floor expressed in bytes while sitting below
67
+ // the floor that floor exists to state. The leading byte is non-zero by the minimal-encoding check
68
+ // above, so its position fixes the total.
69
+ function _modulusBits(n) { return (n.length - 1) * 8 + (32 - Math.clz32(n[0])); }
70
+
60
71
  var ALG_PROFILE = {
61
72
  "-7": { kty: 2, crv: 1 }, "-35": { kty: 2, crv: 2 }, "-36": { kty: 2, crv: 3 },
62
73
  "-9": { kty: 2, crv: 1 }, "-51": { kty: 2, crv: 2 }, "-52": { kty: 2, crv: 3 },
63
74
  "-8": { kty: 1, crv: 6 }, "-19": { kty: 1, crv: 6 }, "-53": { kty: 1, crv: 7 },
64
- "-257": { kty: 3 }, "-258": { kty: 3 }, "-259": { kty: 3 }, "-37": { kty: 3 }, "-65535": { kty: 3 },
75
+ // RSASSA-PSS at all three strengths. PS256 alone left PS384/PS512 refused at PARSE time on a key
76
+ // that is perfectly well-formed -- the same bytes accepted under -37 -- so the refusal blamed the
77
+ // key rather than the algorithm, and a relying party migrating credential rows written by another
78
+ // implementation could not tell which of its stored keys this verifier would decline, or why.
79
+ "-257": { kty: 3 }, "-258": { kty: 3 }, "-259": { kty: 3 },
80
+ "-37": { kty: 3 }, "-38": { kty: 3 }, "-39": { kty: 3 }, "-65535": { kty: 3 },
65
81
  };
66
82
 
67
83
  // credentialKey(node, E, code) -> the decoded + validated credential public key
@@ -72,7 +88,10 @@ var ALG_PROFILE = {
72
88
  // @enforced-by validator-shape-reinlined
73
89
  // @validator-shape kty\s*===\s*2n
74
90
  // @validator-shape EC2_CRV_LEN|ALG_PROFILE
75
- function credentialKey(node, E, code) {
91
+ // `unsupportedCode` is OPTIONAL and names the code raised when the key is well-formed but its
92
+ // algorithm is not one this verifier implements -- a different fact from a malformed key. Omit it
93
+ // and that case keeps raising `code`, so an existing caller sees no change.
94
+ function credentialKey(node, E, code, unsupportedCode) {
76
95
  function bad(msg, cause) { return new E(code, msg, cause); }
77
96
  if (!node || node.majorType !== 5) throw bad("a COSE_Key must be a CBOR map (RFC 9052 sec. 7)");
78
97
  // Every parameter read maps a wrong-type cbor/* fault to the caller's domain -- a
@@ -103,6 +122,34 @@ function credentialKey(node, E, code) {
103
122
  } else if (kty === 3n) {
104
123
  key.n = ib(-1); key.e = ib(-2);
105
124
  if (!key.n || !key.n.length || !key.e || !key.e.length) throw bad("an RSA COSE_Key must carry n (-1) and e (-2)");
125
+ // The MATERIAL, not merely its presence -- the same standard the other two key types are held
126
+ // to, where EC2 pins x/y to the curve's field size and has the point validated on the curve,
127
+ // and OKP pins x to an exact length. Checking only presence let a 1-byte modulus and an
128
+ // exponent of 1 through as conformant credential public keys, and both reach the WebCrypto
129
+ // import, so they reach real signature verification. e = 1 makes RSA the identity function:
130
+ // the "signature" is the message, and it verifies under any modulus.
131
+ // BOTH values first, before either is judged. RFC 8230 sec. 4 encodes n and e as unsigned
132
+ // big-endian integers with no leading zero, and every check below reads a byte LENGTH as though
133
+ // it were a magnitude: the modulus floor, the exponent bound, and the exponent's value. A
134
+ // padded encoding decouples the two, so `00 01` would be read as a two-byte exponent and skip
135
+ // the value check that refuses 1 -- the degenerate key the whole check exists to catch. (An EC2
136
+ // coordinate is the opposite case, fixed-width and zero-padded by definition, which is why this
137
+ // rule is stated for the RSA parameters and not for x/y.)
138
+ if (key.n[0] === 0) throw bad("an RSA COSE_Key modulus (-1) must be minimally encoded, with no leading zero byte (RFC 8230 sec. 4)");
139
+ if (key.e[0] === 0) throw bad("an RSA COSE_Key exponent (-2) must be minimally encoded, with no leading zero byte (RFC 8230 sec. 4)");
140
+ var modulusBits = _modulusBits(key.n);
141
+ if (modulusBits < RSA_MIN_MODULUS_BITS) {
142
+ throw bad("an RSA COSE_Key modulus (-1) is " + modulusBits + " bits, below the " +
143
+ RSA_MIN_MODULUS_BITS + "-bit minimum");
144
+ }
145
+ // e must be odd and greater than 1: RSA needs gcd(e, phi(n)) = 1, so an even exponent is not a
146
+ // valid RSA public exponent at all, and 1 is the degenerate case above. Bounded on the way in
147
+ // so a caller cannot hand over a megabyte of exponent for the modular exponentiation to chew.
148
+ if (key.e.length > RSA_MAX_EXPONENT_BYTES) throw bad("an RSA COSE_Key exponent (-2) is longer than " + RSA_MAX_EXPONENT_BYTES + " bytes");
149
+ if ((key.e[key.e.length - 1] & 1) === 0) throw bad("an RSA COSE_Key exponent (-2) must be odd");
150
+ // Minimal encoding above makes a one-byte e the ONLY way to express a value this small, so the
151
+ // comparison is on the value and not on where it happens to sit.
152
+ if (key.e.length === 1 && key.e[0] <= 1) throw bad("an RSA COSE_Key exponent (-2) must be greater than 1 -- e = 1 makes RSA the identity function");
106
153
  } else {
107
154
  throw bad("unsupported COSE_Key kty " + Number(kty));
108
155
  }
@@ -111,7 +158,12 @@ function credentialKey(node, E, code) {
111
158
  if (node.children.length !== expectedParams) throw bad("the COSE_Key carries parameters beyond the canonical set for its key type (WebAuthn sec. 6.5.1)");
112
159
  // PROFILE: the declared alg must match the key type (and, for EC2, the curve).
113
160
  var prof = ALG_PROFILE[String(key.alg)];
114
- if (!prof) throw bad("unsupported credential key algorithm " + key.alg);
161
+ // An algorithm this verifier does not implement is NOT a malformed key. The key can be perfectly
162
+ // well-formed -- the same bytes may parse under a neighbouring algorithm id -- and a relying
163
+ // party migrating credential rows written elsewhere needs to tell "I cannot check this
164
+ // algorithm" from "these bytes are wrong", since only one of those is fixable by re-registering.
165
+ // Callers that do not distinguish the two pass one code and keep the previous behaviour.
166
+ if (!prof) throw new E(unsupportedCode || code, "unsupported credential key algorithm " + key.alg);
115
167
  if (prof.kty !== key.kty) throw bad("credential key algorithm " + key.alg + " is inconsistent with key type " + key.kty);
116
168
  if (prof.crv != null && prof.crv !== key.crv) throw bad("credential key algorithm " + key.alg + " requires a different curve");
117
169
  // ON-CURVE: import the SPKI so OpenSSL validates the EC point on its curve. An off-curve