@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/CHANGELOG.md +50 -0
- package/README.md +3 -3
- package/index.js +5 -1
- package/lib/cms-verify.js +240 -6
- package/lib/constants.js +7 -4
- package/lib/guard-bytes.js +6 -2
- package/lib/guard-name.js +15 -0
- package/lib/jose.js +39 -0
- package/lib/path-validate.js +72 -36
- package/lib/sign-scheme.js +45 -6
- package/lib/smime.js +41 -6
- package/lib/tsp-sign.js +10 -0
- package/lib/validator-cose.js +55 -3
- package/lib/webauthn-mds.js +207 -23
- package/lib/webauthn.js +447 -140
- package/package.json +2 -2
- package/sbom.cdx.json +6 -6
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,
|
package/lib/path-validate.js
CHANGED
|
@@ -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
|
|
1191
|
-
|
|
1192
|
-
|
|
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 = (
|
|
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
|
package/lib/sign-scheme.js
CHANGED
|
@@ -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
|
-
//
|
|
71
|
-
//
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
581
|
-
* entity bytes), and the `micalg`.
|
|
582
|
-
*
|
|
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
|
}
|
package/lib/validator-cose.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|