@blamejs/pki 0.5.4 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/MIGRATING.md +72 -0
- package/lib/acme.js +47 -19
- package/lib/attrcert-sign.js +11 -7
- package/lib/cmp-session.js +23 -10
- package/lib/cmp-verify.js +12 -4
- package/lib/cms-decrypt.js +71 -30
- package/lib/cms-sign.js +11 -0
- package/lib/cms-verify.js +22 -8
- package/lib/crl-sign.js +19 -7
- package/lib/est.js +77 -1
- package/lib/guard-all.js +8 -0
- package/lib/guard-async.js +37 -0
- package/lib/guard-encoding.js +35 -6
- package/lib/guard-identifier.js +27 -1
- package/lib/guard-json.js +44 -8
- package/lib/guard-name.js +31 -8
- package/lib/guard-parsed.js +482 -0
- package/lib/hpke.js +36 -3
- package/lib/jose.js +8 -0
- package/lib/lint.js +20 -4
- package/lib/merkle.js +9 -0
- package/lib/ocsp.js +44 -8
- package/lib/path-validate.js +88 -44
- package/lib/pbes2.js +11 -1
- package/lib/pkcs12-build.js +105 -30
- package/lib/pki-build.js +12 -1
- package/lib/schema-cms.js +17 -2
- package/lib/schema-crl.js +7 -1
- package/lib/schema-ocsp.js +6 -1
- package/lib/schema-pkcs12.js +7 -2
- package/lib/schema-pkix.js +14 -0
- package/lib/schema-x509.js +14 -1
- package/lib/sign-scheme.js +22 -5
- package/lib/sigstore.js +10 -0
- package/lib/smime.js +47 -0
- package/lib/trust.js +121 -10
- package/lib/tsp-sign.js +112 -19
- package/lib/validator-cose.js +86 -1
- package/lib/validator-tpm.js +8 -3
- package/lib/webauthn-mds.js +10 -18
- package/lib/webauthn.js +40 -18
- package/lib/x509-sign.js +6 -2
- package/package.json +5 -1
- package/sbom.cdx.json +6 -6
package/lib/tsp-sign.js
CHANGED
|
@@ -27,18 +27,40 @@ var guard = require("./guard-all");
|
|
|
27
27
|
var frameworkError = require("./framework-error");
|
|
28
28
|
|
|
29
29
|
var pkix = require("./schema-pkix");
|
|
30
|
+
var pkiBuild = require("./pki-build");
|
|
30
31
|
var TspError = frameworkError.TspError;
|
|
31
32
|
var _NS = pkix.makeNS("tsp", TspError, oid);
|
|
32
33
|
var b = asn1.build;
|
|
33
34
|
function _err(code, message, cause) { return new TspError(code, message, cause); }
|
|
34
35
|
function O(name) { return oid.byName(name); }
|
|
35
36
|
|
|
37
|
+
// The shared authoring-side builder, for the same pre-encoded-Extension validation every other
|
|
38
|
+
// request builder uses (a CSR's extensionRequest, a CRMF CertTemplate, a CRL's entry extensions).
|
|
39
|
+
var _b = pkiBuild.makeBuilder({
|
|
40
|
+
ErrorClass: TspError, prefix: "tsp", O: O, NS: _NS,
|
|
41
|
+
NAME_SCHEMA: pkix.name(_NS), SPKI_SCHEMA: pkix.spki(_NS),
|
|
42
|
+
});
|
|
43
|
+
|
|
36
44
|
// Digest names whose imprint / certHash this producer supports (the SHA-2 family).
|
|
37
45
|
var NODE_DIGEST = { sha256: "sha256", sha384: "sha384", sha512: "sha512" };
|
|
38
46
|
// The message-imprint hash MUST be exactly the digest algorithm's output length (RFC 3161 sec.
|
|
39
47
|
// 2.4.1 -- hashedMessage is "the hash of the datum to be time-stamped").
|
|
40
48
|
var HASH_LEN = { sha256: 32, sha384: 48, sha512: 64 };
|
|
41
49
|
|
|
50
|
+
// How much a `pki.path.validate` revocationChecked outcome ESTABLISHED, ordered. `false` is "no
|
|
51
|
+
// checker ran at all"; "undetermined" is "one ran and could not answer"; "waived" is an
|
|
52
|
+
// undetermined one a caller chose to pass; "determined" is an explicit good-or-revoked answer.
|
|
53
|
+
// Ranking them lets a caller trying several candidate paths keep the most established outcome any
|
|
54
|
+
// of them reached, rather than whichever happened to run last.
|
|
55
|
+
var _REVOCATION_RANK = { "false": 0, "undetermined": 1, "waived": 2, "determined": 3 };
|
|
56
|
+
function _rankRevocation(v) {
|
|
57
|
+
var r = _REVOCATION_RANK[String(v)];
|
|
58
|
+
return r === undefined ? 0 : r; // an unrecognized value establishes nothing
|
|
59
|
+
}
|
|
60
|
+
function _moreEstablished(candidate, current) {
|
|
61
|
+
return _rankRevocation(candidate) > _rankRevocation(current);
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
// A hash AlgorithmIdentifier { OID, NULL } -- messageImprint and ESSCertIDv2 hash algorithms
|
|
43
65
|
// carry an explicit NULL parameter (the form RFC 3161 / RFC 5035 producers emit).
|
|
44
66
|
function _hashAlgId(name) {
|
|
@@ -109,7 +131,13 @@ function _signingCertV2(certDer, hashName) {
|
|
|
109
131
|
* var token = await pki.tsp.sign(imprint, { cert: signerCertDer, key: signerKeyPkcs8 }, { policy: "1.3.6.1.4.1.1", serialNumber: 1 });
|
|
110
132
|
* (await pki.cms.verify(token)).valid; // true
|
|
111
133
|
*/
|
|
134
|
+
// Documented `-> Promise`, so a fault leaves as a REJECTION (guard-async); the checks stay
|
|
135
|
+
// synchronous because they read the caller's mutable imprint, TSA material and options.
|
|
112
136
|
function sign(messageImprint, tsa, opts) {
|
|
137
|
+
return guard.async.deferred(function () { return _sign(messageImprint, tsa, opts); });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _sign(messageImprint, tsa, opts) {
|
|
113
141
|
opts = opts || {};
|
|
114
142
|
if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("tsp/bad-input", "pki.tsp.sign options must be an object");
|
|
115
143
|
var mi = messageImprint || {};
|
|
@@ -130,10 +158,14 @@ function sign(messageImprint, tsa, opts) {
|
|
|
130
158
|
// non-Date or an Invalid Date that would encode a garbage GeneralizedTime).
|
|
131
159
|
if (opts.genTime != null) guard.time.assertValid(opts.genTime, _err, "tsp/bad-input", "genTime");
|
|
132
160
|
var genTime = opts.genTime instanceof Date ? opts.genTime : new Date();
|
|
133
|
-
|
|
161
|
+
// A caller-authored INTEGER is coerced through the shared guard, so a value that is not one is a
|
|
162
|
+
// typed tsp/bad-input rather than a raw SyntaxError or RangeError out of BigInt() -- an untyped
|
|
163
|
+
// fault escaping a public verb, which a caller cannot catch by code.
|
|
164
|
+
var serial = guard.range.authoredInteger(opts.serialNumber, _err, "tsp/bad-input", "serialNumber");
|
|
165
|
+
var fields = [b.integer(1n), _policy(opts.policy), imprint, b.integer(serial), b.generalizedTime(genTime)];
|
|
134
166
|
if (opts.accuracy) fields.push(_accuracy(opts.accuracy));
|
|
135
167
|
if (opts.ordering === true) fields.push(b.boolean(true));
|
|
136
|
-
if (opts.nonce != null) fields.push(b.integer(
|
|
168
|
+
if (opts.nonce != null) fields.push(b.integer(guard.range.authoredInteger(opts.nonce, _err, "tsp/bad-input", "nonce")));
|
|
137
169
|
var tstInfo = b.sequence(fields);
|
|
138
170
|
|
|
139
171
|
var signCert = { type: "signingCertificateV2", values: [_signingCertV2(certDer, certHashAlg)] };
|
|
@@ -237,12 +269,26 @@ function request(messageImprint, opts) {
|
|
|
237
269
|
// extensions in schema order. certReq DEFAULT FALSE -> only an explicit TRUE is encoded (DER).
|
|
238
270
|
var fields = [b.integer(1n), imprint];
|
|
239
271
|
if (opts.reqPolicy != null) fields.push(_policy(opts.reqPolicy));
|
|
240
|
-
if (opts.nonce != null) fields.push(b.integer(
|
|
272
|
+
if (opts.nonce != null) fields.push(b.integer(guard.range.authoredInteger(opts.nonce, _err, "tsp/bad-input", "nonce")));
|
|
241
273
|
if (opts.certReq != null && typeof opts.certReq !== "boolean") throw _err("tsp/bad-input", "certReq must be a boolean");
|
|
242
274
|
if (opts.certReq === true) fields.push(b.boolean(true));
|
|
243
275
|
if (opts.extensions != null) {
|
|
244
276
|
if (!Array.isArray(opts.extensions) || !opts.extensions.every(function (e) { return Buffer.isBuffer(e) || e instanceof Uint8Array; })) throw _err("tsp/bad-input", "extensions must be an array of encoded Extension DER buffers");
|
|
245
|
-
|
|
277
|
+
// Each pre-encoded Extension is validated as one, and its extnID must not repeat -- the same
|
|
278
|
+
// gate every other request builder applies. Spliced in unchecked, a caller relaying a blob it
|
|
279
|
+
// did not author put fully chosen bytes inside [0] and this encoder emitted DER its own
|
|
280
|
+
// parseRequest refuses: an undecodable value, a duplicate extnID, an explicit critical=FALSE
|
|
281
|
+
// the DER DEFAULT rule forbids.
|
|
282
|
+
var seenExt = {};
|
|
283
|
+
var encoded = opts.extensions.map(function (e, i) {
|
|
284
|
+
var der = Buffer.from(e);
|
|
285
|
+
_b.assertValidExtension(der, i);
|
|
286
|
+
var extnId = asn1.read.oid(asn1.decode(der).children[0]);
|
|
287
|
+
if (seenExt[extnId]) throw _err("tsp/bad-input", "duplicate request extension " + extnId + " (RFC 5280 sec. 4.2)");
|
|
288
|
+
seenExt[extnId] = true;
|
|
289
|
+
return der;
|
|
290
|
+
});
|
|
291
|
+
if (encoded.length) fields.push(b.contextConstructed(0, Buffer.concat(encoded)));
|
|
246
292
|
}
|
|
247
293
|
var der = b.sequence(fields);
|
|
248
294
|
return opts.pem ? schemaTsp.pemEncode(der, "TIMESTAMP REQUEST") : der;
|
|
@@ -564,7 +610,8 @@ function _buildTsaChains(leaf, pool) {
|
|
|
564
610
|
* PEM (never a parsed object -- every checked field is read from the CMS-verified eContent, so a
|
|
565
611
|
* mutated parsed structure cannot desynchronize the checks from the signed bytes). `data` is the
|
|
566
612
|
* original bytes (hashed under the token's messageImprint algorithm) or a precomputed
|
|
567
|
-
* `{ hashAlgorithm, hashedMessage }`. Returns `{ valid, trusted,
|
|
613
|
+
* `{ hashAlgorithm, hashedMessage }`. Returns `{ valid, trusted, revocationChecked,
|
|
614
|
+
* anchorConstraints, genTime, accuracy, serialNumber,
|
|
568
615
|
* serialNumberHex, policy, nonce, tsa, tstInfo, signer, code?, reason? }`. `valid` is true only
|
|
569
616
|
* when the CMS signature, the imprint match, the eContentType, the ESSCertID(V2) binding, the
|
|
570
617
|
* RFC 3161 sec. 2.3 critical single-`timeStamping` extendedKeyUsage rule, the requested nonce (when
|
|
@@ -579,11 +626,19 @@ function _buildTsaChains(leaf, pool) {
|
|
|
579
626
|
* accepting one. A timestamp is archived precisely to be re-read years later, and one boolean
|
|
580
627
|
* cannot answer both questions then.
|
|
581
628
|
*
|
|
629
|
+
* `revocationChecked` is the third claim, for the same reason. Revocation runs only when a
|
|
630
|
+
* `revocationChecker` is supplied, so a `trusted` token whose TSA was never checked against a CRL
|
|
631
|
+
* or an OCSP responder reads identically to one established un-revoked -- unless the verdict says
|
|
632
|
+
* which. It is `false` whenever no path ran at all. `anchorConstraints` carries whatever the anchor
|
|
633
|
+
* itself constrained, from `pki.path.validate`.
|
|
634
|
+
*
|
|
582
635
|
* @opts trustAnchor Anchor `{ name, publicKey, algorithm }` -- runs `pki.path.validate` on the
|
|
583
636
|
* TSA certificate chain ordered from the token's embedded certificates
|
|
584
|
-
* (validity at genTime, requiredEku timeStamping, revocation
|
|
585
|
-
*
|
|
586
|
-
*
|
|
637
|
+
* (validity at genTime, requiredEku timeStamping, and revocation when a
|
|
638
|
+
* `revocationChecker` is supplied -- `revocationChecked` reports which), so a
|
|
639
|
+
* TSA under an intermediate CA validates, not only one directly under the
|
|
640
|
+
* anchor. Omit to verify signature + imprint + binding + EKU only and anchor
|
|
641
|
+
* the cert yourself.
|
|
587
642
|
* @opts nonce Require the token's TSTInfo.nonce to equal this (a number/BigInt).
|
|
588
643
|
* @opts reqPolicy Require the token's policy to equal this (an OID name or dotted string).
|
|
589
644
|
* @opts certs Out-of-band TSA certificates (an array of DER `Buffer`s) added to the signer
|
|
@@ -630,8 +685,15 @@ async function verify(token, data, opts) {
|
|
|
630
685
|
// `res.trusted` must get an answer on both branches -- an undefined on the failure path is the
|
|
631
686
|
// same "cannot tell what was checked" the field was added to remove, and `!res.trusted` reading
|
|
632
687
|
// true by accident is not the same as its reading true because nothing anchored the TSA.
|
|
688
|
+
// What a path established travels with the REFUSAL as well. Hardcoding these to false/null here
|
|
689
|
+
// would misreport the one case the field matters most for: a path that ran, established the TSA
|
|
690
|
+
// REVOKED, and refused on that basis did check revocation, and saying it did not is the same
|
|
691
|
+
// "cannot tell what was checked" the field was added to remove. They stay false/null until a path
|
|
692
|
+
// actually produces them, which is the honest answer before one runs.
|
|
693
|
+
var revocationChecked = false;
|
|
694
|
+
var anchorConstraints = null;
|
|
633
695
|
function fail(code, reason) {
|
|
634
|
-
return { valid: false, trusted: false, code: code, reason: reason || null, genTime: tst.genTime, accuracy: tst.accuracy, serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex, policy: tst.policy, nonce: tst.nonce, tsa: tst.tsa, tstInfo: tst, signer: null };
|
|
696
|
+
return { valid: false, trusted: false, revocationChecked: revocationChecked, anchorConstraints: anchorConstraints, code: code, reason: reason || null, genTime: tst.genTime, accuracy: tst.accuracy, serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex, policy: tst.policy, nonce: tst.nonce, tsa: tst.tsa, tstInfo: tst, signer: null };
|
|
635
697
|
}
|
|
636
698
|
// M12 -- the CMS signature over the exact RFC 5652 sec. 5.4 preimage (message-digest bound to the
|
|
637
699
|
// authenticated eContent). cms.verify re-parses the same bytes; a failure is a fail-closed verdict.
|
|
@@ -647,7 +709,7 @@ async function verify(token, data, opts) {
|
|
|
647
709
|
if (mi !== true) return fail(mi);
|
|
648
710
|
// M14 -- if a request nonce is supplied, the token MUST echo it (BigInt-exact).
|
|
649
711
|
if (opts.nonce != null) {
|
|
650
|
-
var wantNonce =
|
|
712
|
+
var wantNonce = guard.range.authoredInteger(opts.nonce, _err, "tsp/bad-input", "opts.nonce");
|
|
651
713
|
if (tst.nonce == null || tst.nonce !== wantNonce) return fail("tsp/nonce-mismatch");
|
|
652
714
|
}
|
|
653
715
|
// M15 -- if the requested policy is supplied, the token's policy MUST equal it.
|
|
@@ -713,23 +775,54 @@ async function verify(token, data, opts) {
|
|
|
713
775
|
// end of the chain and not the other, and a root explicitly distrusted for timestamping would
|
|
714
776
|
// still answer trusted. The purpose is not a caller choice here: this verb validates timestamp
|
|
715
777
|
// tokens and nothing else, so there is exactly one purpose its anchors can be judged under.
|
|
778
|
+
// EVERY path result goes through this one function, so none can be produced without being
|
|
779
|
+
// accumulated. The alternative -- validating in several places and accumulating afterwards --
|
|
780
|
+
// is what let a result be overwritten before it was counted, first across candidate chains and
|
|
781
|
+
// then across the two endpoints of one candidate. There is now one door and it always counts.
|
|
782
|
+
//
|
|
783
|
+
// The two fields accumulate INDEPENDENTLY because they are independent facts. Whether
|
|
784
|
+
// revocation was established and which anchor constraints were consulted do not imply each
|
|
785
|
+
// other: an anchor that rejects the TSA on its own purposes or distrustAfter metadata, with no
|
|
786
|
+
// revocationChecker configured, establishes constraints while establishing nothing about
|
|
787
|
+
// revocation.
|
|
788
|
+
async function validateAt(chain, when) {
|
|
789
|
+
var res = await pathValidate.validate(chain, {
|
|
790
|
+
time: when, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], checkPurpose: "timeStamping", revocationChecker: opts.revocationChecker,
|
|
791
|
+
});
|
|
792
|
+
// The most any attempt ESTABLISHED, kept across every attempt: backtracking tries several
|
|
793
|
+
// chains for one TSA certificate and a fractional genTime validates each at two endpoints,
|
|
794
|
+
// so an attempt that fails earlier than another must not erase what that other established.
|
|
795
|
+
if (_moreEstablished(res.revocationChecked, revocationChecked)) revocationChecked = res.revocationChecked;
|
|
796
|
+
// Every attempt validates against the SAME opts.trustAnchor under the same purpose, so any
|
|
797
|
+
// non-null value describes that one anchor; the first to report them is as good as the last.
|
|
798
|
+
if (anchorConstraints == null && res.anchorConstraints != null) anchorConstraints = res.anchorConstraints;
|
|
799
|
+
return res;
|
|
800
|
+
}
|
|
716
801
|
var chains = _buildTsaChains(pkiX509.parse(tsaCertDer), pool);
|
|
717
802
|
for (var ci = 0; ci < chains.length && !(pathRes && pathRes.valid); ci++) {
|
|
718
|
-
pathRes = await
|
|
719
|
-
|
|
720
|
-
});
|
|
721
|
-
if (pathRes.valid && ceilT !== floorT) {
|
|
722
|
-
pathRes = await pathValidate.validate(chains[ci], {
|
|
723
|
-
time: ceilT, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], checkPurpose: "timeStamping", revocationChecker: opts.revocationChecker,
|
|
724
|
-
});
|
|
725
|
-
}
|
|
803
|
+
pathRes = await validateAt(chains[ci], floorT);
|
|
804
|
+
if (pathRes.valid && ceilT !== floorT) pathRes = await validateAt(chains[ci], ceilT);
|
|
726
805
|
}
|
|
727
806
|
} catch (e) { return fail("tsp/untrusted-tsa", (e && e.message) || String(e)); }
|
|
807
|
+
// On a REFUSAL the question is whether ANYTHING established the TSA's revocation status, so the
|
|
808
|
+
// accumulated best answers it: a candidate that reached the checker and was rejected as revoked
|
|
809
|
+
// did check revocation, and saying otherwise would misdescribe the reason for the refusal.
|
|
728
810
|
if (!pathRes || !pathRes.valid) return fail("tsp/untrusted-tsa", "the TSA certificate did not validate to the trust anchor at genTime");
|
|
811
|
+
// On ACCEPTANCE the question is narrower and the accumulated best would OVERCLAIM: what matters
|
|
812
|
+
// is what was checked on the path actually accepted, not what some rejected candidate managed.
|
|
813
|
+
// The loop stops at the first valid path, so `pathRes` is that path.
|
|
814
|
+
revocationChecked = pathRes.revocationChecked;
|
|
815
|
+
anchorConstraints = pathRes.anchorConstraints;
|
|
816
|
+
// Reduced to a bare `trusted: true`, this answered the same way whether the TSA was established
|
|
817
|
+
// un-revoked or revocation was never consulted at all -- and revocation only runs when a
|
|
818
|
+
// revocationChecker is supplied, so the second case is the DEFAULT. A caller archiving a
|
|
819
|
+
// timestamp verdict could not tell the two apart later, which is the distinction the
|
|
820
|
+
// valid/trusted split exists to keep.
|
|
729
821
|
trusted = true;
|
|
730
822
|
}
|
|
731
823
|
return {
|
|
732
|
-
valid: true, trusted: trusted,
|
|
824
|
+
valid: true, trusted: trusted, revocationChecked: revocationChecked, anchorConstraints: anchorConstraints,
|
|
825
|
+
genTime: tst.genTime, accuracy: tst.accuracy,
|
|
733
826
|
serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex,
|
|
734
827
|
policy: tst.policy, policyName: tst.policyName, nonce: tst.nonce, tsa: tst.tsa,
|
|
735
828
|
tstInfo: tst, signer: { cert: tsaCertDer, sid: signer.sid },
|
package/lib/validator-cose.js
CHANGED
|
@@ -156,6 +156,90 @@ function credentialKey(node, E, code, unsupportedCode) {
|
|
|
156
156
|
// CANONICAL CTAP2 COSE_Key: exactly the type's parameters, nothing more.
|
|
157
157
|
var expectedParams = kty === 2n ? 5 : 4;
|
|
158
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)");
|
|
159
|
+
return assertKeyMaterial(key, E, code, unsupportedCode);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// assertKeyMaterial(key, E, code, unsupportedCode) -> key | throws
|
|
163
|
+
//
|
|
164
|
+
// The rules about the KEY, split from the rules about its CBOR encoding, because the
|
|
165
|
+
// toolkit accepts a stored credential key in two forms and both reach a signature
|
|
166
|
+
// verification. `pki.webauthn.verifyAssertion` takes the COSE bytes or the object
|
|
167
|
+
// `pki.webauthn.verify` returned; the bytes went through every check below and the
|
|
168
|
+
// object went through none, so the same 1-byte modulus was refused as a credential key
|
|
169
|
+
// in one form and imported for verification in the other. A caller stores whichever
|
|
170
|
+
// form their datastore round-trips, which is not a choice about how carefully their
|
|
171
|
+
// credential is checked.
|
|
172
|
+
//
|
|
173
|
+
// Everything above this line is about the ENCODING -- a CBOR map, integer labels, byte
|
|
174
|
+
// strings, the canonical parameter count -- and can only be asked of bytes. Everything
|
|
175
|
+
// here is about the key, and is asked of both.
|
|
176
|
+
//
|
|
177
|
+
// @enforced-by behavioral -- key-material rules have no rename-proof code shape distinct
|
|
178
|
+
// from ordinary length and byte comparisons; the RED vectors that drive BOTH accepted
|
|
179
|
+
// forms of a stored credential key (the COSE bytes and the object) through
|
|
180
|
+
// pki.webauthn.verifyAssertion with an undersized modulus, e = 1, a curve/length
|
|
181
|
+
// mismatch and a short OKP x are the guard.
|
|
182
|
+
function assertKeyMaterial(key, E, code, unsupportedCode) {
|
|
183
|
+
function bad(msg, cause) { return new E(code, msg, cause); }
|
|
184
|
+
if (!key || typeof key !== "object") throw bad("a credential key must be a decoded COSE_Key object");
|
|
185
|
+
// ONE read of each field, into a plain object, before anything is checked or used.
|
|
186
|
+
//
|
|
187
|
+
// The object form comes from the caller, so any of these can be an accessor. One that THROWS
|
|
188
|
+
// turns a validation into a raw fault -- the thing this function exists to prevent -- and one
|
|
189
|
+
// that returns DIFFERENT values on successive reads makes the field that was checked and the
|
|
190
|
+
// field that is used two different values, which is the check defeated rather than merely
|
|
191
|
+
// reported badly. Reading each exactly once settles both, and settles them for every field
|
|
192
|
+
// rather than for the ones a particular branch happens to reach.
|
|
193
|
+
try {
|
|
194
|
+
key = { kty: key.kty, alg: key.alg, crv: key.crv, x: key.x, y: key.y, n: key.n, e: key.e };
|
|
195
|
+
} catch (e) { throw bad("a credential key field could not be read", e); }
|
|
196
|
+
// Then the TYPE and the VALUE. BigInt() throws a raw TypeError on a Symbol and on undefined, and
|
|
197
|
+
// a raw RangeError on a fractional or non-finite number, so "it is a number" is not the check --
|
|
198
|
+
// "it is an integer" is. EVERY integer label the branches below read, not the two the dispatch
|
|
199
|
+
// happens to need first:
|
|
200
|
+
// crv indexes a lookup table, and a Symbol thrown at a property read is the same raw fault as a
|
|
201
|
+
// Symbol thrown at BigInt(). The decoded form gets these from the CBOR reader, which has already
|
|
202
|
+
// established them; the object form gets them from the caller, so this is where they are settled.
|
|
203
|
+
// A BigInt is bounded too. COSE labels are small registry integers, and an unbounded one converts
|
|
204
|
+
// to Infinity, which then throws a raw RangeError at the next conversion -- the same defeat as a
|
|
205
|
+
// fractional number, reached by a value that IS an integer. "It is an integer" is not the whole
|
|
206
|
+
// check either; "it is an integer this code can carry" is.
|
|
207
|
+
var MAX = BigInt(Number.MAX_SAFE_INTEGER);
|
|
208
|
+
function _isInt(v) {
|
|
209
|
+
if (typeof v === "bigint") return v <= MAX && v >= -MAX;
|
|
210
|
+
return typeof v === "number" && Number.isSafeInteger(v);
|
|
211
|
+
}
|
|
212
|
+
if (!_isInt(key.kty)) throw bad("a COSE_Key kty (label 1) must be an integer");
|
|
213
|
+
if (!_isInt(key.alg)) throw bad("a COSE_Key alg (label 3) must be an integer");
|
|
214
|
+
if (key.crv !== undefined && key.crv !== null && !_isInt(key.crv)) throw bad("a COSE_Key crv (label -1) must be an integer");
|
|
215
|
+
// ONE representation from here down. A label may arrive as a Number or a BigInt -- a CBOR reader
|
|
216
|
+
// hands out BigInt, an object built in JavaScript is likelier to hold Number -- and everything
|
|
217
|
+
// below compares with === against the Number-keyed profile table and the curve tables. Accepting
|
|
218
|
+
// both forms at the gate and then comparing only one is a check that answers by how the caller
|
|
219
|
+
// happened to spell the value; the decoded arm normalizes here too, for the same reason.
|
|
220
|
+
key.kty = Number(key.kty);
|
|
221
|
+
key.alg = Number(key.alg);
|
|
222
|
+
if (key.crv !== undefined && key.crv !== null) key.crv = Number(key.crv);
|
|
223
|
+
var kty = BigInt(key.kty);
|
|
224
|
+
if (kty === 2n) {
|
|
225
|
+
var el2 = EC2_CRV_LEN[key.crv];
|
|
226
|
+
if (!Buffer.isBuffer(key.x) || !Buffer.isBuffer(key.y)) throw bad("an EC2 COSE_Key must carry crv (-1), x (-2), and y (-3)");
|
|
227
|
+
if (!el2 || key.x.length !== el2 || key.y.length !== el2) throw bad("an EC2 COSE_Key x/y length is inconsistent with its curve");
|
|
228
|
+
} else if (kty === 1n) {
|
|
229
|
+
var okp2 = OKP_CRV[key.crv];
|
|
230
|
+
if (!okp2 || !Buffer.isBuffer(key.x) || key.x.length !== okp2.len) throw bad("an OKP COSE_Key must be Ed25519 (crv 6) or Ed448 (crv 7) with a matching-length x (-2)");
|
|
231
|
+
} else if (kty === 3n) {
|
|
232
|
+
if (!Buffer.isBuffer(key.n) || !key.n.length || !Buffer.isBuffer(key.e) || !key.e.length) throw bad("an RSA COSE_Key must carry n (-1) and e (-2)");
|
|
233
|
+
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)");
|
|
234
|
+
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)");
|
|
235
|
+
var bits = _modulusBits(key.n);
|
|
236
|
+
if (bits < RSA_MIN_MODULUS_BITS) throw bad("an RSA COSE_Key modulus (-1) is " + bits + " bits, below the " + RSA_MIN_MODULUS_BITS + "-bit minimum");
|
|
237
|
+
if (key.e.length > RSA_MAX_EXPONENT_BYTES) throw bad("an RSA COSE_Key exponent (-2) is longer than " + RSA_MAX_EXPONENT_BYTES + " bytes");
|
|
238
|
+
if ((key.e[key.e.length - 1] & 1) === 0) throw bad("an RSA COSE_Key exponent (-2) must be odd");
|
|
239
|
+
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");
|
|
240
|
+
} else {
|
|
241
|
+
throw bad("unsupported COSE_Key kty " + Number(key.kty));
|
|
242
|
+
}
|
|
159
243
|
// PROFILE: the declared alg must match the key type (and, for EC2, the curve).
|
|
160
244
|
var prof = ALG_PROFILE[String(key.alg)];
|
|
161
245
|
// An algorithm this verifier does not implement is NOT a malformed key. The key can be perfectly
|
|
@@ -173,7 +257,7 @@ function credentialKey(node, E, code, unsupportedCode) {
|
|
|
173
257
|
// OpenSSL does NOT validate an OKP (Ed25519/Ed448) point on import -- an all-zeroes key
|
|
174
258
|
// parses, and even verifies a trivial signature -- so an OKP point needs an explicit
|
|
175
259
|
// on-curve + full-order (non-low-order) check (RFC 8032 decode + the cofactor check).
|
|
176
|
-
if (kty ===
|
|
260
|
+
if (Number(key.kty) === 1 && !edwardsPoint.validate(key.x, key.crv)) throw bad("the OKP credential public key is not a valid, full-order Edwards point");
|
|
177
261
|
return key;
|
|
178
262
|
}
|
|
179
263
|
|
|
@@ -216,6 +300,7 @@ function toSpki(key, E, code) {
|
|
|
216
300
|
|
|
217
301
|
module.exports = {
|
|
218
302
|
credentialKey: credentialKey,
|
|
303
|
+
assertKeyMaterial: assertKeyMaterial,
|
|
219
304
|
toSpki: toSpki,
|
|
220
305
|
EC2_CRV_LEN: EC2_CRV_LEN,
|
|
221
306
|
EC2_CRV_OID: EC2_CRV_OID,
|
package/lib/validator-tpm.js
CHANGED
|
@@ -221,12 +221,17 @@ function normalizeObjectAttributePolicy(policy, E, code) {
|
|
|
221
221
|
// either of which could match a key this policy was written to exclude, including the
|
|
222
222
|
// Empty Policy. An entry that is not a Buffer or a canonical even-length hex string is a
|
|
223
223
|
// caller error, and it fails here rather than becoming a digest nobody intended.
|
|
224
|
+
// Through guard.encoding.hex, which owns the alphabet, the even-length rule and the
|
|
225
|
+
// canonical round-trip -- the same three checks written here by hand, and now written
|
|
226
|
+
// once. It also decodes, so the hex path cannot validate one string and decode another.
|
|
224
227
|
allow = ap.allow.map(function (entry, i) {
|
|
225
228
|
if (Buffer.isBuffer(entry)) return entry;
|
|
226
|
-
|
|
227
|
-
|
|
229
|
+
var label = "opts.tpmPolicy.authPolicy.allow[" + i + "]";
|
|
230
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
231
|
+
throw new E(code, label + " must be a Buffer or an even-length hex string");
|
|
228
232
|
}
|
|
229
|
-
return
|
|
233
|
+
return guard.encoding.hex(entry, null, function (c, m) { return new E(c, m); }, code,
|
|
234
|
+
label + " must be a Buffer or an even-length hex string --");
|
|
230
235
|
});
|
|
231
236
|
}
|
|
232
237
|
}
|
package/lib/webauthn-mds.js
CHANGED
|
@@ -212,24 +212,16 @@ function _isAnchorItself(cert, anchor) {
|
|
|
212
212
|
// a hand-built object literal satisfies it too and then raises a raw TypeError from deep inside the
|
|
213
213
|
// path validator, which is an untyped throw escaping a public verb.
|
|
214
214
|
function _asCert(v, label) {
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// `validity` is what separates a certificate from the other signed structures that carry a
|
|
226
|
-
// subject, a public key and a signature algorithm: a parsed certification request has all
|
|
227
|
-
// three and would otherwise be installed as a trust anchor.
|
|
228
|
-
v.validity && v.validity.notBefore !== undefined) {
|
|
229
|
-
return v;
|
|
230
|
-
}
|
|
231
|
-
try { return x509.parse(v); }
|
|
232
|
-
catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
|
|
215
|
+
// Through the shared certificate door: these become the anchors a metadata BLOB's signer chain is
|
|
216
|
+
// judged against, and an anchor's identity is its subject and its key. A caller-assembled object
|
|
217
|
+
// could carry a real root's subject beside a substituted key -- every field well-formed, nothing
|
|
218
|
+
// for a shape test to catch -- so the object is re-derived from the bytes its parser read instead.
|
|
219
|
+
// Bytes in any form (a Buffer, a typed-array view, a DataView, an ArrayBuffer) are parsed here;
|
|
220
|
+
// refusing one of those would make the accepted set depend on how the caller received the file.
|
|
221
|
+
return guard.parsed.acceptDerived(v, "certificate", function (bytes) {
|
|
222
|
+
try { return x509.parse(bytes); }
|
|
223
|
+
catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
|
|
224
|
+
}, _err, "webauthn/bad-input", label);
|
|
233
225
|
}
|
|
234
226
|
|
|
235
227
|
// Verify a FIDO Metadata Service BLOB and return its entries indexed for lookup. `blob` is the
|
package/lib/webauthn.js
CHANGED
|
@@ -572,8 +572,13 @@ function _snapshotRoots(supplied) {
|
|
|
572
572
|
if (_isBufferSource(root)) {
|
|
573
573
|
return guard.bytes.snapshotSource(root, WebauthnError, "webauthn/bad-input", "opts.rootCertificates[]");
|
|
574
574
|
}
|
|
575
|
-
|
|
576
|
-
|
|
575
|
+
// A PARSED certificate is kept AS IT IS, not deep-copied. The copy was this function's way of
|
|
576
|
+
// stopping a caller mutating a root after it was accepted, and it solved that by making a
|
|
577
|
+
// detached twin -- which loses the parser's record, so the door below could no longer re-derive
|
|
578
|
+
// the anchor from the bytes it was read from. The record is the stronger form of the same
|
|
579
|
+
// protection: the anchor is re-parsed from those bytes, so an edit made afterwards, at any depth,
|
|
580
|
+
// is discarded rather than copied. Cloning would trade that for a snapshot of a mutable object.
|
|
581
|
+
return root; // a PEM string is immutable; a parsed certificate carries its own provenance
|
|
577
582
|
});
|
|
578
583
|
}
|
|
579
584
|
|
|
@@ -592,13 +597,16 @@ function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
|
|
|
592
597
|
// would fault on a field it does not have rather than naming the caller's mistake.
|
|
593
598
|
// The same three forms opts.safetyNetRoots takes, since it is the same question.
|
|
594
599
|
var roots = supplied.map(function (root, i) {
|
|
595
|
-
var
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
600
|
+
var label = "opts.rootCertificates[" + i + "]";
|
|
601
|
+
// These BECOME trust anchors, which is the sharpest form of the rule: the anchor's key is what
|
|
602
|
+
// the whole attestation chain is judged against, so a caller-assembled certificate carrying a
|
|
603
|
+
// real root's name beside a substituted key would anchor an attacker's chain. Re-derived from
|
|
604
|
+
// the bytes its parser read, exactly as at every other certificate door.
|
|
605
|
+
return guard.parsed.acceptDerived(root, "certificate", function (bytes) {
|
|
606
|
+
try {
|
|
607
|
+
return x509.parse(_isBufferSource(bytes) ? guard.bytes.source(bytes, WebauthnError, "webauthn/bad-input", label) : bytes);
|
|
608
|
+
} catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
|
|
609
|
+
}, _err, "webauthn/bad-input", label);
|
|
602
610
|
});
|
|
603
611
|
// Same rule the metadata route applies: a compound element carrying no
|
|
604
612
|
// certificates makes no claim there is anything to anchor, so it is not a reason to
|
|
@@ -1174,14 +1182,15 @@ function _safetyNetHostnameOk(leaf) {
|
|
|
1174
1182
|
function _safetyNetChainTrusted(chain, roots, time) {
|
|
1175
1183
|
var anchors;
|
|
1176
1184
|
try {
|
|
1185
|
+
// The same door opts.rootCertificates goes through, for the same reason: these become trust
|
|
1186
|
+
// anchors, and an anchor's key is what the chain is judged against.
|
|
1177
1187
|
anchors = roots.map(function (root, i) {
|
|
1178
|
-
var
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
throw _err("webauthn/bad-input",
|
|
1183
|
-
}
|
|
1184
|
-
return anchorCert;
|
|
1188
|
+
var label = "opts.safetyNetRoots[" + i + "]";
|
|
1189
|
+
return guard.parsed.acceptDerived(root, "certificate", function (bytes) {
|
|
1190
|
+
try {
|
|
1191
|
+
return x509.parse(_isBufferSource(bytes) ? guard.bytes.source(bytes, WebauthnError, "webauthn/bad-input", label) : bytes);
|
|
1192
|
+
} catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
|
|
1193
|
+
}, _err, "webauthn/bad-input", label);
|
|
1185
1194
|
});
|
|
1186
1195
|
} catch (e) { return Promise.reject(e); }
|
|
1187
1196
|
return mds.chainToAnchor(chain, anchors, time === undefined ? new Date() : time,
|
|
@@ -2228,7 +2237,13 @@ function _snapshotAssertion(input) {
|
|
|
2228
2237
|
var key = {}, kk;
|
|
2229
2238
|
for (kk in out.credentialPublicKey) {
|
|
2230
2239
|
if (!Object.prototype.hasOwnProperty.call(out.credentialPublicKey, kk)) continue;
|
|
2231
|
-
|
|
2240
|
+
// The read itself can fault: a stored key is a caller-supplied object, so a field may be an
|
|
2241
|
+
// accessor, and one that THROWS would escape as a raw error from the very copy whose job is
|
|
2242
|
+
// to make the descriptor stop being the caller's. The copy is the boundary, so the fault is
|
|
2243
|
+
// named here rather than surfacing from wherever the field was later used.
|
|
2244
|
+
var v;
|
|
2245
|
+
try { v = out.credentialPublicKey[kk]; }
|
|
2246
|
+
catch (e) { throw _err("webauthn/bad-cose-key", "credentialPublicKey." + kk + " could not be read", e); }
|
|
2232
2247
|
key[kk] = _isBufferSource(v) ? guard.bytes.snapshotSource(v, WebauthnError, "webauthn/bad-input", "credentialPublicKey." + kk) : v;
|
|
2233
2248
|
}
|
|
2234
2249
|
out.credentialPublicKey = key;
|
|
@@ -2295,7 +2310,14 @@ function verifyAssertion(input) {
|
|
|
2295
2310
|
var coseKey = input.credentialPublicKey;
|
|
2296
2311
|
if (Buffer.isBuffer(coseKey) || ArrayBuffer.isView(coseKey) || coseKey instanceof ArrayBuffer) {
|
|
2297
2312
|
coseKey = parseCoseKey(coseKey);
|
|
2298
|
-
} else if (
|
|
2313
|
+
} else if (_isPlainObject(coseKey)) {
|
|
2314
|
+
// The OBJECT form is held to the same rules about the KEY as the bytes form. The bytes went
|
|
2315
|
+
// through the curve/length, RSA modulus-floor and exponent checks; the object went through
|
|
2316
|
+
// none, so one stored credential was refused in one form and imported for signature
|
|
2317
|
+
// verification in the other. Which form a relying party stores is a question about what their
|
|
2318
|
+
// datastore round-trips, not about how carefully their credential is checked.
|
|
2319
|
+
coseKey = validator.cose.assertKeyMaterial(coseKey, WebauthnError, "webauthn/bad-cose-key", "webauthn/unsupported-algorithm");
|
|
2320
|
+
} else {
|
|
2299
2321
|
throw _err("webauthn/bad-input", "credentialPublicKey must be the stored COSE key -- the object pki.webauthn.verify returned, or its COSE bytes");
|
|
2300
2322
|
}
|
|
2301
2323
|
var bindingChecked = _applyBindings(authData, coseKey, input);
|
package/lib/x509-sign.js
CHANGED
|
@@ -286,8 +286,12 @@ function _sign(spec, issuer, opts) {
|
|
|
286
286
|
issuerDer = subjectDer;
|
|
287
287
|
issuerSpki = spki;
|
|
288
288
|
} else if (issuer.cert != null) {
|
|
289
|
-
|
|
290
|
-
|
|
289
|
+
// The CA-ness gate, the issuer name and the signing key are all read off this object, so a
|
|
290
|
+
// partial one decides them on fields that are not there.
|
|
291
|
+
// Re-derived from the bytes its parser read. The issuer certificate's subject and key identifier
|
|
292
|
+
// are copied into the certificate being signed, so they are a claim about who issued it -- an
|
|
293
|
+
// assembled object could name an issuer whose bytes the signer never saw.
|
|
294
|
+
issuerCert = guard.parsed.acceptDerived(issuer.cert, "certificate", x509.parse, _err, "x509/bad-input", "issuer.cert");
|
|
291
295
|
issuerPathLen = _assertIssuerIsCa(issuerCert);
|
|
292
296
|
issuerDer = pkiBuild.tbsNameField(issuerCert, "subject");
|
|
293
297
|
issuerSpki = issuerCert.subjectPublicKeyInfo.bytes;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/pki",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"url": "https://github.com/blamejs/pki/issues"
|
|
14
14
|
},
|
|
15
15
|
"main": "index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./index.js",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
16
20
|
"bin": {
|
|
17
21
|
"pki": "bin/pki.js"
|
|
18
22
|
},
|
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:
|
|
5
|
+
"serialNumber": "urn:uuid:f56149c0-58f9-40e1-a0f0-36a0dd2e4ee9",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-16T03:23:32.715Z",
|
|
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.5.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.5.6",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.5.
|
|
25
|
+
"version": "0.5.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.5.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.5.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.5.
|
|
57
|
+
"ref": "@blamejs/pki@0.5.6",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|