@blamejs/pki 0.5.5 → 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 +20 -0
- package/MIGRATING.md +41 -0
- package/lib/acme.js +47 -19
- package/lib/cms-sign.js +11 -0
- package/lib/cms-verify.js +22 -8
- package/lib/guard-all.js +2 -0
- package/lib/guard-async.js +37 -0
- package/lib/guard-parsed.js +46 -7
- package/lib/ocsp.js +6 -0
- package/lib/pbes2.js +11 -1
- package/lib/pkcs12-build.js +71 -23
- package/lib/schema-cms.js +17 -2
- package/lib/sigstore.js +10 -0
- package/lib/tsp-sign.js +112 -19
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to `@blamejs/pki` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## v0.5.6 — 2026-08-16
|
|
8
|
+
|
|
9
|
+
A CMS SignedData is verified over the bytes it was parsed from, an omitted PKCS#12 password is refused rather than encoded as the empty one, and a verb documented as returning a Promise rejects instead of throwing past your .catch.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- A verb documented as returning a Promise now rejects; it never throws synchronously. An operator reads -> Promise<...> in the reference and writes the documented shape, pki.cms.sign(content, signers).catch(handleIt) -- and a validation that ran before the promise was created threw straight past that .catch, so a misspelled option or a malformed input became an uncaught exception in code that already handles errors. Nothing in the shape of the call said which verbs did it. Eleven did: pki.cms.verify, pki.cms.sign, pki.cms.countersign, pki.ocsp.sign, pki.tsp.sign, and six pki.acme verbs. Nine more on the client pki.acme.client(...) returns did the same, including getAuthorization, which an ordinary ACME loop reaches with a URL that came from the CA rather than from the caller. The checks still run synchronously, so a caller's mutable options are read before any turn passes; only the way a fault leaves has changed. If you wrapped one of these verbs in a synchronous try/catch without awaiting it -- which the documented signature never supported, but which worked by accident on exactly these verbs -- that catch no longer fires and the rejection surfaces as an unhandled one instead. Await the call, or attach .catch to it.
|
|
14
|
+
- pki.tsp.verify reports whether revocation was established, not only whether the timestamp authority chained. Revocation runs only when a revocationChecker is supplied, so a token whose TSA was never checked against a CRL or an OCSP responder read identically to one established un-revoked -- and that is the default. The verdict now carries revocationChecked and anchorConstraints from the path validation, in pki.path.validate's own vocabulary, so a timestamp archived to be re-read years later can still answer what was actually checked. The trustAnchor documentation no longer lists revocation unconditionally.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- pki.cms.verify computes its verdict over the bytes the parser read. A SignedData's meaning is a signature over a byte range, but a parsed one presents that range, the signature, the algorithms and the certificates as separate properties, and the verb read them as though the parser had produced them together. The forgery that follows is concrete: take a message a trusted signer really signed, keep its SignerInfo and signature untouched, set signedAttrsBytes to null, and present the signature's own preimage -- the signed attributes re-tagged as the SET OF the signature covers -- as the encapsulated content under any content type you like. With no signed attributes the signature is checked against the content directly, which is exactly those bytes, and neither the content-type nor the message-digest check runs, so the message verifies as valid content the signer never signed. pki.schema.cms.parse now records what it read, pki.cms.verify re-derives from that record, and a SignedData a caller assembled rather than parsed is refused with a typed error instead of dereferenced into a raw TypeError. Passing DER, PEM, or the parser's own unmodified result is unaffected.
|
|
19
|
+
- pki.pkcs12.build refuses a store with no password instead of building one under the empty password. An omitted password and the empty password are different credentials, and the difference was invisible at the call site: pki.pkcs12.build(spec) with no options at all, or with the option name misspelled, returned a well-formed store whose shrouded private key opened under "" -- a key protected by nothing, with no error anywhere. The empty password is still available; it has to be asked for, as "".
|
|
20
|
+
- pki.pkcs12.build validates opts.integrity.mode against the value it accepts. Compared against a single literal, any other spelling read as "not public-key" and silently selected password integrity, dropping the signer with it: a caller who wrote mode: 'publicKey' asked for a CMS signature over the AuthenticatedSafe and got a password MAC. Combined with the previous item, a caller who misspelled both options got a store MACed under the empty password while believing it was signature-protected. An unrecognized key on opts.integrity is refused too.
|
|
21
|
+
- pki.pkcs12.open bounds the key-derivation work of a modern store, not only a legacy one. The aggregate budget existed because a per-bag iteration cap resets on every bag, so a store that repeats a costly bag up to the parser's element limit multiplies the cap by that limit -- but it was charged only on the RFC 7292 Appendix C path, leaving the RFC 8018 PBES2 path -- the one OpenSSL and NSS emit, and the one this toolkit itself builds -- free to do exactly that. Measured before the fix: ten bags at a million iterations each ran ten million aggregate rounds of blocking PBKDF2 with no refusal. Both schemes now charge one budget, and it is charged before the derivation runs rather than after.
|
|
22
|
+
- A PKCS#12 password this toolkit encoded is cleared once the derivation has consumed it, on the PBES2 and PBMAC1 paths as well as the classic ones. The Appendix B.1 copy taken from a password argument was wiped while the UTF-8 copy taken from the same argument in the same call was not, leaving a plaintext password in the heap after every modern bag encryption, bag decryption and MAC. A Buffer you supply is never written to: it is yours, and clearing it would destroy your credential rather than protect it.
|
|
23
|
+
- pki.tsp.request validates a pre-encoded extension instead of splicing it in. Elements of opts.extensions were checked only for being byte-like and then concatenated straight into the request, so a caller relaying an extension blob it did not author put fully chosen bytes into the structure -- and the encoder emitted requests its own pki.tsp.parseRequest refuses: an undecodable value, a repeated extension identifier, an explicit critical=FALSE that DER requires be omitted. Each element now goes through the same pre-encoded-Extension gate every other request builder in the toolkit applies, including the duplicate-identifier check.
|
|
24
|
+
- pki.tsp.sign, pki.tsp.request and pki.tsp.verify report a bad serial number or nonce as a typed tsp/bad-input. A value that is not an integer reached BigInt() directly and raised a raw SyntaxError or RangeError out of a public verb -- an untyped fault a caller handling tsp/* codes cannot catch.
|
|
25
|
+
- pki.sigstore.verifyBundle refuses an option it does not recognize. The identity policy one level down already did, for the reason that applies just as much at the top: the signer pin goes by other names in other Sigstore tooling, and a spelling this verb swallowed checked nothing under a name the operator believed pinned the signer. At the top level the same slip also loses the SLSA predicate pin, and unlike the identity fields there is no report field to reveal it -- the verdict said verified: true with the signer and the predicate entirely unpinned.
|
|
26
|
+
|
|
7
27
|
## v0.5.5 — 2026-08-15
|
|
8
28
|
|
|
9
29
|
A verdict is computed over the bytes the parser read, an identity is derived from the bytes that carry it, and the guards match no patterns.
|
package/MIGRATING.md
CHANGED
|
@@ -14,6 +14,47 @@ The toolkit has no `deprecate()`-marked surface awaiting removal.
|
|
|
14
14
|
|
|
15
15
|
Listed newest-first.
|
|
16
16
|
|
|
17
|
+
### v0.5.6 — `try { pki.<verb>(...) } catch`
|
|
18
|
+
|
|
19
|
+
A verb documented `-> Promise` rejects on a bad input instead of throwing before the promise exists.
|
|
20
|
+
|
|
21
|
+
If you awaited the call, or attached `.catch`, nothing changes and there is nothing to do.
|
|
22
|
+
|
|
23
|
+
What changes is the undocumented shape: a synchronous `try`/`catch` that never consumed the
|
|
24
|
+
returned promise.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
try {
|
|
28
|
+
pki.cms.verify(bytes); // no await, no .catch
|
|
29
|
+
} catch (e) { /* used to fire on a malformed input */ }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That `catch` no longer runs, and the rejection surfaces as an unhandled one. It worked by
|
|
33
|
+
accident on exactly the verbs where a check happened to run before the promise existed --
|
|
34
|
+
`pki.cms.verify`, `pki.cms.sign`, `pki.cms.countersign`, `pki.ocsp.sign`, `pki.tsp.sign`, six
|
|
35
|
+
`pki.acme` verbs, and nine verbs on the client `pki.acme.client(...)` returns. Which verbs
|
|
36
|
+
those were was not visible from the call, which is why they are now uniform.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
await pki.cms.verify(bytes); // or pki.cms.verify(bytes).catch(handleIt)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### v0.5.6 — `pki.pkcs12.build(spec)`
|
|
43
|
+
|
|
44
|
+
An omitted password is refused rather than encoded as the empty one.
|
|
45
|
+
|
|
46
|
+
A store whose password option was missing or misspelled no longer builds silently under `""`.
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
await pki.pkcs12.build(spec); // now pkcs12/bad-input
|
|
50
|
+
await pki.pkcs12.build(spec, { password: "" }); // the empty password, asked for
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If you were relying on the default, the second form restores the previous output byte for
|
|
54
|
+
byte. `opts.integrity.mode` is validated the same way: a spelling other than `"public-key"`
|
|
55
|
+
is now `pkcs12/bad-integrity-mode` instead of silently selecting password integrity and
|
|
56
|
+
dropping the signer.
|
|
57
|
+
|
|
17
58
|
### v0.5.5 — `require("@blamejs/pki/lib/...")`
|
|
18
59
|
|
|
19
60
|
The package resolves one entry point; a path into the package no longer resolves.
|
package/lib/acme.js
CHANGED
|
@@ -684,6 +684,12 @@ function _payloadBuf(obj) {
|
|
|
684
684
|
if (obj === undefined) return Buffer.alloc(0); // POST-as-GET
|
|
685
685
|
return Buffer.from(JSON.stringify(obj), "utf8");
|
|
686
686
|
}
|
|
687
|
+
// A verb documented `-> Promise` refuses by REJECTING, never by throwing (guard-async).
|
|
688
|
+
//
|
|
689
|
+
// The validation stays SYNCHRONOUS: these verbs read a caller's options object -- the key, the
|
|
690
|
+
// nonce, the identifiers -- and resolving those before any turn passes is what stops a value being
|
|
691
|
+
// swapped between the check and the use. Only the exit changes.
|
|
692
|
+
var _promised = guard.async.deferred;
|
|
687
693
|
function _signOuter(o, payloadObj) {
|
|
688
694
|
if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
|
|
689
695
|
if (!o.key) throw E("acme/bad-input", "a signing key (opts.key) is required");
|
|
@@ -707,10 +713,12 @@ function _signOuter(o, payloadObj) {
|
|
|
707
713
|
* await pki.acme.postAsGet({ key, alg: "ES256", nonce, url: orderUrl, kid });
|
|
708
714
|
*/
|
|
709
715
|
function postAsGet(o) {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
716
|
+
return _promised(function () {
|
|
717
|
+
if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
|
|
718
|
+
// An authenticated read is ALWAYS kid-signed; copy only the kid-mode fields so a
|
|
719
|
+
// leftover jwk (e.g. reused from a newAccount options object) cannot embed a key.
|
|
720
|
+
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, undefined);
|
|
721
|
+
});
|
|
714
722
|
}
|
|
715
723
|
|
|
716
724
|
// A contact URL (RFC 8555 sec. 7.3): a `mailto:` addr-spec carries no header
|
|
@@ -750,6 +758,7 @@ function _assertContacts(contacts) {
|
|
|
750
758
|
* await pki.acme.newAccount({ key, alg: "ES256", nonce, url, jwk, termsOfServiceAgreed: true });
|
|
751
759
|
*/
|
|
752
760
|
function newAccount(o) {
|
|
761
|
+
return _promised(function () {
|
|
753
762
|
if (!_isObject(o) || !_isObject(o.jwk)) throw E("acme/bad-input", "newAccount must embed the account public jwk (RFC 8555 sec. 7.3)");
|
|
754
763
|
var payload = {};
|
|
755
764
|
if (o.contact !== undefined) { _assertContacts(o.contact); payload.contact = o.contact; }
|
|
@@ -769,6 +778,7 @@ function newAccount(o) {
|
|
|
769
778
|
payload.externalAccountBinding = o.externalAccountBinding;
|
|
770
779
|
}
|
|
771
780
|
return jose.sign({ protected: { alg: o.alg, nonce: o.nonce, url: o.url, jwk: o.jwk }, payload: _payloadBuf(payload), key: o.key, jwk: o.jwk, profile: "acme-outer" });
|
|
781
|
+
});
|
|
772
782
|
}
|
|
773
783
|
|
|
774
784
|
var _HMAC_HASH = { HS256: "SHA-256", HS384: "SHA-384", HS512: "SHA-512" };
|
|
@@ -855,6 +865,7 @@ function _validateOrderIdentifier(id) {
|
|
|
855
865
|
* await pki.acme.newOrder({ key, alg: "ES256", nonce, url, kid, identifiers: [{ type: "dns", value: "example.org" }] });
|
|
856
866
|
*/
|
|
857
867
|
function newOrder(o) {
|
|
868
|
+
return _promised(function () {
|
|
858
869
|
if (!_isObject(o) || !Array.isArray(o.identifiers) || o.identifiers.length === 0) throw E("acme/bad-order", "newOrder requires a non-empty identifiers array (RFC 8555 sec. 7.4)");
|
|
859
870
|
// Serialize the CANONICAL { type, value } each validator returns, never the caller's objects (which may carry
|
|
860
871
|
// getter-backed / inherited fields JSON.stringify would drop, or extra enumerable fields it would send).
|
|
@@ -863,6 +874,7 @@ function newOrder(o) {
|
|
|
863
874
|
if (o.notAfter !== undefined) { if (!_isRfc3339(o.notAfter)) throw E("acme/bad-order", "notAfter must be an RFC 3339 date-time"); payload.notAfter = o.notAfter; }
|
|
864
875
|
if (o.replaces !== undefined) { if (!_isString(o.replaces)) throw E("acme/bad-order", "replaces must be an ARI certID string (RFC 9773 sec. 5)"); payload.replaces = o.replaces; }
|
|
865
876
|
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload);
|
|
877
|
+
});
|
|
866
878
|
}
|
|
867
879
|
|
|
868
880
|
/**
|
|
@@ -1035,10 +1047,12 @@ async function finalize(o) {
|
|
|
1035
1047
|
* await pki.acme.challengeResponse({ key, alg: "ES256", nonce, url: challUrl, kid });
|
|
1036
1048
|
*/
|
|
1037
1049
|
function challengeResponse(o) {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1050
|
+
return _promised(function () {
|
|
1051
|
+
if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
|
|
1052
|
+
var payload = o.payload !== undefined ? o.payload : {};
|
|
1053
|
+
if (!_isObject(payload)) throw E("acme/bad-input", "a challenge response payload must be a JSON object (RFC 8555 sec. 7.5.1)");
|
|
1054
|
+
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload);
|
|
1055
|
+
});
|
|
1042
1056
|
}
|
|
1043
1057
|
|
|
1044
1058
|
/**
|
|
@@ -1061,8 +1075,10 @@ function challengeResponse(o) {
|
|
|
1061
1075
|
* await pki.acme.deactivate({ key, alg: "ES256", nonce, url: authzUrl, kid });
|
|
1062
1076
|
*/
|
|
1063
1077
|
function deactivate(o) {
|
|
1064
|
-
|
|
1065
|
-
|
|
1078
|
+
return _promised(function () {
|
|
1079
|
+
if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
|
|
1080
|
+
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, { status: "deactivated" });
|
|
1081
|
+
});
|
|
1066
1082
|
}
|
|
1067
1083
|
|
|
1068
1084
|
/**
|
|
@@ -1091,6 +1107,7 @@ function deactivate(o) {
|
|
|
1091
1107
|
* await pki.acme.revokeCert({ key, alg: "ES256", nonce, url, kid, certificate: certDer, reason: 1 });
|
|
1092
1108
|
*/
|
|
1093
1109
|
function revokeCert(o) {
|
|
1110
|
+
return _promised(function () {
|
|
1094
1111
|
if (!_isObject(o) || !Buffer.isBuffer(o.certificate)) throw E("acme/bad-input", "revokeCert requires a DER certificate Buffer (opts.certificate)");
|
|
1095
1112
|
x509.parse(o.certificate); // structural validation of the target
|
|
1096
1113
|
var hasKid = Object.prototype.hasOwnProperty.call(o, "kid");
|
|
@@ -1106,6 +1123,7 @@ function revokeCert(o) {
|
|
|
1106
1123
|
var header = { alg: o.alg, nonce: o.nonce, url: o.url };
|
|
1107
1124
|
if (hasKid) header.kid = o.kid; else header.jwk = o.jwk;
|
|
1108
1125
|
return jose.sign({ protected: header, payload: _payloadBuf(payload), key: o.key, jwk: o.jwk, profile: "acme-outer" });
|
|
1126
|
+
});
|
|
1109
1127
|
}
|
|
1110
1128
|
|
|
1111
1129
|
/**
|
|
@@ -2015,12 +2033,18 @@ function client(directoryUrl, opts) {
|
|
|
2015
2033
|
});
|
|
2016
2034
|
});
|
|
2017
2035
|
}
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
function
|
|
2036
|
+
// A bad URL leaves as a REJECTION, like every other verb on this client. It is not only a caller
|
|
2037
|
+
// typo that reaches here: the message layer deliberately accepts http as well as https, so an
|
|
2038
|
+
// order whose `authorizations` array carries an http URL validates, and the very next step --
|
|
2039
|
+
// getAuthorization(order.authorizations[0]) -- threw out of the middle of an async ACME loop
|
|
2040
|
+
// rather than rejecting into the handler already wrapped around it.
|
|
2041
|
+
function _getOrder(url) { return _promised(function () { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("order", _json(res)); }); }); }
|
|
2042
|
+
function _getAuthorization(url) { return _promised(function () { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("authorization", _json(res)); }); }); }
|
|
2043
|
+
function _getChallenge(url) { return _promised(function () { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("challenge", _json(res)); }); }); }
|
|
2044
|
+
function _respondToChallenge(url) { return _promised(function () { return _post(_clientUrl(url), challengeResponse, null, "kid").then(function (res) { return validate("challenge", _json(res)); }); }); }
|
|
2045
|
+
|
|
2046
|
+
function _finalize(order, o) { return _promised(function () { return _finalizeBody(order, o); }); }
|
|
2047
|
+
function _finalizeBody(order, o) {
|
|
2024
2048
|
o = o || {};
|
|
2025
2049
|
if (!_isObject(order) || !_isString(order.finalize)) throw E("acme/bad-input", "finalize requires the order object with its finalize URL");
|
|
2026
2050
|
if (!Array.isArray(order.identifiers)) throw E("acme/bad-input", "finalize requires the order's identifiers to enforce the RFC 8555 sec. 7.4 CSR-set match");
|
|
@@ -2155,10 +2179,11 @@ function client(directoryUrl, opts) {
|
|
|
2155
2179
|
});
|
|
2156
2180
|
}
|
|
2157
2181
|
|
|
2158
|
-
function _deactivateAccount() { return _post(_requireKid(), deactivate, null, "kid").then(function (res) { return validate("account", _json(res)); }); }
|
|
2159
|
-
function _deactivateAuthorization(url) { return _post(_clientUrl(url), deactivate, null, "kid").then(function (res) { return validate("authorization", _json(res)); }); }
|
|
2182
|
+
function _deactivateAccount() { return _promised(function () { return _post(_requireKid(), deactivate, null, "kid").then(function (res) { return validate("account", _json(res)); }); }); }
|
|
2183
|
+
function _deactivateAuthorization(url) { return _promised(function () { return _post(_clientUrl(url), deactivate, null, "kid").then(function (res) { return validate("authorization", _json(res)); }); }); }
|
|
2160
2184
|
|
|
2161
|
-
function _keyChange(o) {
|
|
2185
|
+
function _keyChange(o) { return _promised(function () { return _keyChangeBody(o); }); }
|
|
2186
|
+
function _keyChangeBody(o) {
|
|
2162
2187
|
o = o || {};
|
|
2163
2188
|
if (!o.newKey || !_isObject(o.newJwk) || !_isString(o.newAlg)) throw E("acme/bad-input", "keyChange requires newKey, newJwk, and newAlg");
|
|
2164
2189
|
var account = _requireKid();
|
|
@@ -2181,6 +2206,9 @@ function client(directoryUrl, opts) {
|
|
|
2181
2206
|
|
|
2182
2207
|
// ARI RenewalInfo (RFC 9773 sec. 4.1) -- the SOLE UNAUTHENTICATED GET (no JWS, no nonce).
|
|
2183
2208
|
function _renewalInfo(certDer, clockFn, retryAfterCapSeconds) {
|
|
2209
|
+
return _promised(function () { return _renewalInfoBody(certDer, clockFn, retryAfterCapSeconds); });
|
|
2210
|
+
}
|
|
2211
|
+
function _renewalInfoBody(certDer, clockFn, retryAfterCapSeconds) {
|
|
2184
2212
|
if (!Buffer.isBuffer(certDer)) throw E("acme/bad-input", "renewalInfo requires a DER certificate Buffer");
|
|
2185
2213
|
var _rawClk = typeof clockFn === "function" ? clockFn : clock; // renewalWindow may pass a per-call clock
|
|
2186
2214
|
// The clock MUST return finite epoch ms: a NaN / Infinity would make the expiry comparison below silently
|
package/lib/cms-sign.js
CHANGED
|
@@ -337,7 +337,13 @@ function _pemToDer(text) {
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
// pki.cms.sign -- documented by the @primitive block in cms-verify.js (the @module pki.cms home).
|
|
340
|
+
// Documented `-> Promise`, so a fault leaves as a REJECTION (guard-async); the checks stay
|
|
341
|
+
// synchronous because they read the caller's mutable content and signer list.
|
|
340
342
|
function sign(content, signers, opts) {
|
|
343
|
+
return guard.async.deferred(function () { return _sign(content, signers, opts); });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function _sign(content, signers, opts) {
|
|
341
347
|
opts = opts || {};
|
|
342
348
|
if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("cms/bad-input", "pki.cms.sign options must be an object");
|
|
343
349
|
var contentBuf = _toBuf(content, "content");
|
|
@@ -533,7 +539,12 @@ function _targetPreimage(siNode, opts) {
|
|
|
533
539
|
}
|
|
534
540
|
|
|
535
541
|
// pki.cms.countersign -- documented by the @primitive block in cms-verify.js (the @module pki.cms home).
|
|
542
|
+
// Documented `-> Promise`, so a fault leaves as a REJECTION (guard-async).
|
|
536
543
|
function countersign(cmsInput, signers, opts) {
|
|
544
|
+
return guard.async.deferred(function () { return _countersign(cmsInput, signers, opts); });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function _countersign(cmsInput, signers, opts) {
|
|
537
548
|
opts = opts || {};
|
|
538
549
|
if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("cms/bad-input", "pki.cms.countersign options must be an object");
|
|
539
550
|
var list = Array.isArray(signers) ? signers : [signers];
|
package/lib/cms-verify.js
CHANGED
|
@@ -538,7 +538,10 @@ function _surfaceUnsignedAttrs(si) {
|
|
|
538
538
|
// Verify every countersignature attached to `si` (RFC 5652 sec. 11.4): each id-countersignature
|
|
539
539
|
// value is a SignerInfo over `si`'s signature octets. Returns per-countersignature verdicts; a
|
|
540
540
|
// countersignature's OWN countersignatures verify over ITS signature octets (recursive). A
|
|
541
|
-
//
|
|
541
|
+
// countersignature that FAILS TO VERIFY is surfaced ok:false -- never silently dropped, and never
|
|
542
|
+
// allowed to change the primary verdict. A countersignature value that is not a well-formed
|
|
543
|
+
// SignerInfo does not reach here at all: the decoder validates every id-countersignature value by
|
|
544
|
+
// content rather than on the attribute type, so such a message is refused whole. The recursion
|
|
542
545
|
// terminates because it only walks the FINITE parsed structure: each nested countersignature value
|
|
543
546
|
// is a sub-encoding of its parent, and the strict decoder already bounds total nesting by
|
|
544
547
|
// C.LIMITS.DER_MAX_DEPTH at parse (CWE-834/770), so a hostile deep chain fails closed before verify.
|
|
@@ -650,7 +653,14 @@ function _snapshotIfBytes(input, label) {
|
|
|
650
653
|
// capability cannot arrive with its option silently ignored at this boundary.
|
|
651
654
|
var _VERIFY_OPTS = { certs: 1, content: 1, trustAnchors: 1, time: 1, requiredEku: 1, checkPurpose: 1 };
|
|
652
655
|
|
|
656
|
+
// Documented `-> Promise`, so a fault leaves as a REJECTION (guard-async). The checks below stay
|
|
657
|
+
// synchronous -- they read a caller's mutable options and bytes, and resolving those before any turn
|
|
658
|
+
// passes is what stops a value being swapped between the check and the use.
|
|
653
659
|
function verify(input, opts) {
|
|
660
|
+
return guard.async.deferred(function () { return _verify(input, opts); });
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function _verify(input, opts) {
|
|
654
664
|
opts = opts || {};
|
|
655
665
|
if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("cms/bad-input", "pki.cms.verify options must be an object");
|
|
656
666
|
// An unrecognized option is refused, not swallowed. This is what kept the missing trust seam
|
|
@@ -663,13 +673,17 @@ function verify(input, opts) {
|
|
|
663
673
|
// the parse surfaced -- the signed content above all -- stays a view into the
|
|
664
674
|
// caller's memory across that await, and a buffer rewritten in the gap would leave
|
|
665
675
|
// the result describing one message while the signature was checked over another.
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
676
|
+
// Re-derived from the bytes the parser read, never trusted as the object it arrives as. A
|
|
677
|
+
// SignedData's meaning is a signature over a byte range, but a parsed one presents that range
|
|
678
|
+
// (`signedAttrsBytes`, or the encapsulated `eContent` when there are no signed attributes), the
|
|
679
|
+
// signature, the algorithms and the certificates as SEPARATE properties. Keep a genuine signer's
|
|
680
|
+
// signature and signed attributes and put different content beside them and every part of this
|
|
681
|
+
// check passes for a message that signer never signed -- the forgery this verb's own block claims
|
|
682
|
+
// to defend (CWE-347). A duck-type test on `signerInfos` cannot see that, because every field is
|
|
683
|
+
// individually well-formed; only re-deriving them all from one byte string can.
|
|
684
|
+
var parsed = guard.parsed.acceptDerived(input, "cms", function (bytes) {
|
|
685
|
+
return cms.parse(_snapshotIfBytes(bytes, "pki.cms.verify"));
|
|
686
|
+
}, _err, "cms/bad-input", "the SignedData");
|
|
673
687
|
if (!Array.isArray(parsed.signerInfos)) throw _err("cms/bad-input", "input is not a CMS SignedData");
|
|
674
688
|
var content = parsed.encapContentInfo.eContent;
|
|
675
689
|
if (content == null) {
|
package/lib/guard-all.js
CHANGED
|
@@ -65,6 +65,7 @@ var header = require("./guard-header");
|
|
|
65
65
|
var compress = require("./guard-compress");
|
|
66
66
|
var secret = require("./guard-secret");
|
|
67
67
|
var parsed = require("./guard-parsed");
|
|
68
|
+
var async_ = require("./guard-async");
|
|
68
69
|
|
|
69
70
|
module.exports = {
|
|
70
71
|
bytes: bytes,
|
|
@@ -81,4 +82,5 @@ module.exports = {
|
|
|
81
82
|
compress: compress,
|
|
82
83
|
secret: secret,
|
|
83
84
|
parsed: parsed,
|
|
85
|
+
async: async_,
|
|
84
86
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- no operator-facing namespace. The documented surface is every verb whose
|
|
6
|
+
// @signature says `-> Promise<...>`; this is how each of them refuses.
|
|
7
|
+
//
|
|
8
|
+
// guard-async -- a verb documented as Promise-returning refuses by REJECTING, never by throwing.
|
|
9
|
+
//
|
|
10
|
+
// The failure is invisible at the call site, which is what makes it worth a choke point. An operator
|
|
11
|
+
// reads `-> Promise<...>` in the reference and writes the documented shape:
|
|
12
|
+
//
|
|
13
|
+
// pki.acme.newOrder(opts).catch(handleIt);
|
|
14
|
+
//
|
|
15
|
+
// A validation that runs BEFORE the promise is created throws straight past that `.catch`, so a
|
|
16
|
+
// misspelled option or a malformed input becomes an uncaught exception in code that already handles
|
|
17
|
+
// errors -- and nothing in the shape of the call tells the caller which verbs do that. Eleven verbs
|
|
18
|
+
// across five modules had it (pki.cms.verify / sign / countersign, pki.ocsp.sign, pki.tsp.sign and
|
|
19
|
+
// six pki.acme verbs), each having grown the same way: a cheap synchronous check added at the top of
|
|
20
|
+
// a function that returns a promise further down.
|
|
21
|
+
//
|
|
22
|
+
// What this does NOT change is WHEN the work happens. The body still runs synchronously, because
|
|
23
|
+
// several of these verbs must resolve a caller's mutable options object before any turn passes --
|
|
24
|
+
// reading a key, a nonce, or a request's bytes a turn later is a different value than the one that
|
|
25
|
+
// was checked. Only the exit changes: a fault leaves as a rejection instead of a throw.
|
|
26
|
+
//
|
|
27
|
+
// @enforced-by behavioral -- the rule has no rename-proof code shape (it is the ABSENCE of a wrapper
|
|
28
|
+
// around a synchronous prefix, which no lexical pattern can see). The guard is the derived test
|
|
29
|
+
// test/layer-0-primitives/promise-contract.test.js, which reads every `-> Promise<` @signature out
|
|
30
|
+
// of lib/ and calls each verb to prove the refusal arrives as a rejection -- so a new verb is in
|
|
31
|
+
// scope the day it is documented, and no list here can go stale.
|
|
32
|
+
function deferred(body) {
|
|
33
|
+
try { return Promise.resolve(body()); }
|
|
34
|
+
catch (e) { return Promise.reject(e); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { deferred: deferred };
|
package/lib/guard-parsed.js
CHANGED
|
@@ -342,12 +342,45 @@ function recordingParser(kind, parse, ErrorClass, code, label) {
|
|
|
342
342
|
};
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
//
|
|
345
|
+
// recordingWalker(kind, walkNode, decodeBytes) -> function (node) -> the walked structure.
|
|
346
|
+
//
|
|
347
|
+
// The recordingParser sibling for a producer handed an already-DECODED node rather than bytes.
|
|
348
|
+
// One structure needs it: the RFC 7292 authSafe's SignedData. A PFX is decoded BER-tolerantly
|
|
349
|
+
// because real stores carry the indefinite-length encoding, so its inner SignedData cannot be
|
|
350
|
+
// re-derived by the strict `parse` entry the byte doors use -- that entry would refuse a store
|
|
351
|
+
// this toolkit accepts today. The record therefore names the bytes AND how to walk them again.
|
|
352
|
+
//
|
|
353
|
+
// The same three properties hold as for recordingParser, and for the same reasons. The record can
|
|
354
|
+
// only be obtained by actually running the walk, so no code can assert provenance for a structure
|
|
355
|
+
// it did not produce. The recorded bytes are a private COPY of the node's, because the walked
|
|
356
|
+
// result surfaces views onto the buffer the PFX was decoded from and a caller holding one could
|
|
357
|
+
// otherwise write through it into the very bytes the record names. And `derive` re-runs THIS
|
|
358
|
+
// walker over that copy, so a re-derivation reproduces the structure exactly -- the decode is the
|
|
359
|
+
// producer's own, never a stricter one substituted at the door.
|
|
360
|
+
//
|
|
361
|
+
// @enforced-by guard-shape-reinlined -- shares recordingParser's shape below: the PROVENANCE.set
|
|
362
|
+
// that binds a structure to the bytes it was derived from appears only in this module, so no
|
|
363
|
+
// producer elsewhere can mint a record for a structure it did not itself walk.
|
|
364
|
+
function recordingWalker(kind, walkNode, decodeBytes) {
|
|
365
|
+
return function (node) {
|
|
366
|
+
var out = walkNode(node);
|
|
367
|
+
if (out && typeof out === "object" && node && _isBytes(node.bytes)) {
|
|
368
|
+
PROVENANCE.set(out, {
|
|
369
|
+
kind: kind,
|
|
370
|
+
source: Buffer.from(node.bytes),
|
|
371
|
+
derive: function (src) { return walkNode(decodeBytes(src)); },
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The provenance record for `obj`, or undefined. @internal to this module: touches no
|
|
346
379
|
// property of `obj` at all -- the lookup is on the object's identity.
|
|
347
|
-
function
|
|
380
|
+
function _recordOf(obj, kind) {
|
|
348
381
|
if (!obj || typeof obj !== "object") return undefined;
|
|
349
382
|
var rec = PROVENANCE.get(obj);
|
|
350
|
-
return (rec && rec.kind === kind) ? rec
|
|
383
|
+
return (rec && rec.kind === kind) ? rec : undefined;
|
|
351
384
|
}
|
|
352
385
|
|
|
353
386
|
// fromTrustedSource(input, kind, claimFields, parse, E, code, why) -> the parsed
|
|
@@ -368,8 +401,10 @@ function _sourceOf(obj, kind) {
|
|
|
368
401
|
// @guard-via guard\.parsed\.(?:fromTrustedSource|recordingParser)\(
|
|
369
402
|
function fromTrustedSource(input, kind, claimFields, parse, E, code, why) {
|
|
370
403
|
if (input && typeof input === "object" && !_isBytes(input)) {
|
|
371
|
-
|
|
372
|
-
|
|
404
|
+
// A structure the walker recorded carries its own way back; the byte doors re-derive with the
|
|
405
|
+
// parser they were given.
|
|
406
|
+
var rec = _recordOf(input, kind);
|
|
407
|
+
if (rec !== undefined) return (rec.derive || parse)(rec.source);
|
|
373
408
|
for (var i = 0; i < claimFields.length; i++) {
|
|
374
409
|
// A claim field can be an accessor that throws; a guard answers, it does not relay.
|
|
375
410
|
var claims;
|
|
@@ -404,10 +439,12 @@ function fromTrustedSource(input, kind, claimFields, parse, E, code, why) {
|
|
|
404
439
|
var _CLAIMS = {
|
|
405
440
|
certificate: ["tbsBytes", "subjectPublicKeyInfo", "serialNumberHex"],
|
|
406
441
|
crl: ["tbsBytes", "revokedCertificates", "crlExtensions"],
|
|
442
|
+
cms: ["signerInfos", "encapContentInfo"],
|
|
407
443
|
};
|
|
408
444
|
var _WHY = {
|
|
409
445
|
certificate: "the signed byte range, the signature and the fields that range encodes are separate properties of a parsed object, so a REBUILT certificate (Object.assign, spread, a JSON round-trip) could have them describe different certificates -- keep a real CA certificate's signed bytes and signature and replace only its public key and every field is still well-formed",
|
|
410
446
|
crl: "the signed byte range, the revocation list and the scope extensions are separate properties of a parsed object, so a REBUILT CRL could have them describe different CRLs -- empty the revocation list and a correctly signed CRL reports a revoked certificate as good",
|
|
447
|
+
cms: "the signed attribute bytes, the signature, the encapsulated content and the certificates that verify it are separate properties of a parsed object, so a REBUILT SignedData could have them describe different messages -- keep a genuine signer's signature and signed attributes and put other content beside them, and every part of the check passes for content that signer never signed",
|
|
411
448
|
};
|
|
412
449
|
// @enforced-by guard-shape-reinlined -- shares the fromTrustedSource shape it composes: a door that
|
|
413
450
|
// tests the claim fields itself, rather than routing here, is the re-inline both replace.
|
|
@@ -431,13 +468,15 @@ function acceptDerived(input, kind, parse, E, code, label) {
|
|
|
431
468
|
throw E(code, who + " must be a " + kind + " DER Buffer, a PEM string, or a parsed " + kind);
|
|
432
469
|
}
|
|
433
470
|
}
|
|
471
|
+
var ns = { certificate: "x509", crl: "crl", cms: "cms" }[kind];
|
|
434
472
|
return fromTrustedSource(input, kind, claims, parse, E, code,
|
|
435
|
-
who + " must be its DER bytes, a PEM string, or an unmodified pki.schema." +
|
|
436
|
-
|
|
473
|
+
who + " must be its DER bytes, a PEM string, or an unmodified pki.schema." + ns +
|
|
474
|
+
".parse result: " + _WHY[kind]);
|
|
437
475
|
}
|
|
438
476
|
|
|
439
477
|
module.exports = {
|
|
440
478
|
accept: accept, acceptDerived: acceptDerived,
|
|
441
479
|
fromTrustedSource: fromTrustedSource, recordingParser: recordingParser,
|
|
480
|
+
recordingWalker: recordingWalker,
|
|
442
481
|
isCert: certShape, isCrl: crlShape,
|
|
443
482
|
};
|
package/lib/ocsp.js
CHANGED
|
@@ -256,7 +256,13 @@ function _normCertDer(cert, what) {
|
|
|
256
256
|
* { responderID: "byName", responses: [{ cert: leafDer, issuer: caDer, status: "good" }] },
|
|
257
257
|
* { cert: responderCertDer, key: responderPkcs8 });
|
|
258
258
|
*/
|
|
259
|
+
// Documented `-> Promise`, so a fault leaves as a REJECTION (guard-async); the checks stay
|
|
260
|
+
// synchronous because they read the responder's mutable cert and key.
|
|
259
261
|
function sign(responseData, responder, opts) {
|
|
262
|
+
return guard.async.deferred(function () { return _sign(responseData, responder, opts); });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function _sign(responseData, responder, opts) {
|
|
260
266
|
opts = opts || {};
|
|
261
267
|
responseData = responseData || {};
|
|
262
268
|
if (!responder || responder.cert == null || responder.key == null) throw _err("ocsp/bad-input", "a responder must be { cert, key }");
|
package/lib/pbes2.js
CHANGED
|
@@ -177,7 +177,12 @@ function pbes2Encrypt(pwBytes, plaintext, opts, E, prefix) {
|
|
|
177
177
|
// <prefix>/bad-input a param guard raises is normalized to the structural <prefix>/bad-algorithm-parameters.
|
|
178
178
|
// A wrong key / bad PKCS#7 pad collapses to the UNIFORM <prefix>/decrypt-failed (RFC 8018 sec. 8). The
|
|
179
179
|
// plaintext integrity re-check (re-parse as a PrivateKeyInfo / SafeContents) is the CALLER's step.
|
|
180
|
-
|
|
180
|
+
// `budget` (optional) is a shared { rounds } tally a caller decrypting MANY structures under one
|
|
181
|
+
// call charges against, so the per-structure iteration cap cannot simply reset each time: PBKDF2
|
|
182
|
+
// runs on the event loop, and a store that repeats a costly bag up to the parser's element limit
|
|
183
|
+
// otherwise multiplies the cap by that limit. A caller decrypting exactly one structure passes
|
|
184
|
+
// nothing and is bounded by the cap alone.
|
|
185
|
+
function pbes2Decrypt(pwBytes, params, ciphertext, opts, E, prefix, budget) {
|
|
181
186
|
var keyBits, iv, pb;
|
|
182
187
|
try {
|
|
183
188
|
var p = seqChildren(params, 2, "PBES2 parameters", E, prefix);
|
|
@@ -197,6 +202,11 @@ function pbes2Decrypt(pwBytes, params, ciphertext, opts, E, prefix) {
|
|
|
197
202
|
}
|
|
198
203
|
throw E(prefix + "/bad-algorithm-parameters", "malformed PBES2 parameters", e);
|
|
199
204
|
}
|
|
205
|
+
// Charge the shared budget BEFORE deriving, so the work is refused rather than performed.
|
|
206
|
+
if (budget) {
|
|
207
|
+
budget.rounds -= pb.iterations;
|
|
208
|
+
if (budget.rounds < 0) throw E(prefix + "/iteration-limit", "the aggregate PBKDF2 key-derivation work exceeds the budget (a hostile many-element input)");
|
|
209
|
+
}
|
|
200
210
|
var dk = nodeCrypto.pbkdf2Sync(pwBytes, pb.salt, pb.iterations, keyBits / 8, pb.prfNode);
|
|
201
211
|
try { return cbcDecrypt(dk, iv, ciphertext, keyBits); }
|
|
202
212
|
catch (_e) { throw E(prefix + "/decrypt-failed", "decryption failed"); }
|
package/lib/pkcs12-build.js
CHANGED
|
@@ -69,12 +69,14 @@ var MAX_PBMAC1_KEYLEN = 1024; // an HMAC key beyond a hash block is pointless
|
|
|
69
69
|
// pkcs12-local (its KDF is bespoke here) while PBMAC1 reuses the toolkit-wide PBKDF2 ceiling. 1e6 is ~500x
|
|
70
70
|
// the OpenSSL default (2048) yet still bounds the loop to ~1 second.
|
|
71
71
|
var CLASSIC_MAC_MAX_ITERATIONS = 1000000;
|
|
72
|
-
// The AGGREGATE cap on synchronous
|
|
73
|
-
//
|
|
74
|
-
// hostile store that duplicates a costly
|
|
75
|
-
// the event loop for minutes; this bounds the total to
|
|
76
|
-
//
|
|
77
|
-
|
|
72
|
+
// The AGGREGATE cap on synchronous KDF work across a single open() -- rounds summed over every encrypted
|
|
73
|
+
// bag and safe, whichever scheme it uses: the legacy App. B KDF (its block count x iterations) and PBKDF2
|
|
74
|
+
// alike. The per-bag iteration cap resets per bag, so a hostile store that duplicates a costly bag up to the
|
|
75
|
+
// parser's 1024-element limit would otherwise block the event loop for minutes; this bounds the total to
|
|
76
|
+
// ~the classic MAC ceiling. It covers BOTH schemes because covering only the legacy one left the modern
|
|
77
|
+
// path -- the one every current producer emits -- free to multiply its cap by the element limit.
|
|
78
|
+
// A conforming store runs only a few thousand rounds (its handful of bags at ~2048 iterations).
|
|
79
|
+
var KDF_MAX_ROUNDS = CLASSIC_MAC_MAX_ITERATIONS;
|
|
78
80
|
|
|
79
81
|
// The classic App. B.2 KDF (u = hash output bytes, v = compression block bytes) per RFC 7292 App. B.2.
|
|
80
82
|
var P12_KDF_UV = {
|
|
@@ -102,6 +104,18 @@ var DIGEST_NAME = { sha1: "sha1", sha256: "sha256", sha384: "sha384", sha512: "s
|
|
|
102
104
|
// is taken verbatim as already-formatted bytes (an escape hatch for a caller that pre-encodes).
|
|
103
105
|
function _p12Password(pw) { return _p12PasswordOwned(pw).bytes; }
|
|
104
106
|
|
|
107
|
+
// An ABSENT password is refused rather than encoded as the empty one. The two are not the same
|
|
108
|
+
// credential, and the difference is invisible at the call site: a caller who misspells the option,
|
|
109
|
+
// or threads it through a layer that drops it, otherwise gets a store whose private key is
|
|
110
|
+
// protected by nothing and no error anywhere saying so. The empty password remains available --
|
|
111
|
+
// it just has to be asked for, as "".
|
|
112
|
+
var _MISSING_PASSWORD = "a password must be a string, Buffer, or Uint8Array -- an omitted password " +
|
|
113
|
+
"is not the empty password; pass \"\" to use the empty one deliberately";
|
|
114
|
+
|
|
115
|
+
// Every field opts.integrity reads. Adding one here is the only way to make it accepted, so a
|
|
116
|
+
// capability cannot arrive with its option silently ignored at this boundary.
|
|
117
|
+
var _INTEGRITY_OPTS = { mode: 1, signer: 1, signers: 1, certificates: 1, sid: 1, signingTime: 1 };
|
|
118
|
+
|
|
105
119
|
// The same encoding, reporting OWNERSHIP -- mirroring pbes2.passwordBytesOwned. A caller-supplied
|
|
106
120
|
// Buffer is returned AS-IS and is BORROWED: clearing it would destroy the caller's own credential,
|
|
107
121
|
// which is a worse defect than leaving a copy readable. Every other input is re-encoded into a
|
|
@@ -111,10 +125,9 @@ function _p12PasswordOwned(pw) {
|
|
|
111
125
|
return { bytes: _p12Encode(pw), owned: true };
|
|
112
126
|
}
|
|
113
127
|
function _p12Encode(pw) {
|
|
114
|
-
if (pw == null) pw = "";
|
|
115
128
|
if (Buffer.isBuffer(pw)) return pw;
|
|
116
129
|
if (pw instanceof Uint8Array) return Buffer.from(pw);
|
|
117
|
-
if (typeof pw !== "string") throw _err("pkcs12/bad-input",
|
|
130
|
+
if (typeof pw !== "string") throw _err("pkcs12/bad-input", _MISSING_PASSWORD);
|
|
118
131
|
var out = Buffer.alloc(pw.length * 2 + 2); // + the 2-byte NULL terminator
|
|
119
132
|
for (var i = 0; i < pw.length; i++) {
|
|
120
133
|
var u = pw.charCodeAt(i);
|
|
@@ -130,12 +143,19 @@ function _p12Encode(pw) {
|
|
|
130
143
|
// PBKDF2 the raw UTF-8 password for these modern schemes (confirmed byte-for-byte against `openssl pkcs12`),
|
|
131
144
|
// reserving the BMPString+NULL form for the bespoke Appendix B KDF only. A file we emit must open in OpenSSL,
|
|
132
145
|
// so the modern schemes use UTF-8 here; only the classic Appendix B MAC uses `_p12Password`.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
146
|
+
// The UTF-8 encoding, reporting OWNERSHIP -- the sibling of _p12PasswordOwned, and it exists
|
|
147
|
+
// for the same reason. A caller-supplied Buffer is BORROWED and left alone; every other input is
|
|
148
|
+
// re-encoded into a buffer this module allocated, and a plaintext password copy this module made
|
|
149
|
+
// is cleared once the derivation has consumed it. Without it the App. B.1 copy taken from the same
|
|
150
|
+
// argument in the same call was wiped while this one was not.
|
|
151
|
+
function _pbePasswordOwned(pw) {
|
|
152
|
+
if (Buffer.isBuffer(pw)) return { bytes: pw, owned: false };
|
|
153
|
+
if (pw instanceof Uint8Array) return { bytes: Buffer.from(pw), owned: true };
|
|
154
|
+
if (typeof pw !== "string") throw _err("pkcs12/bad-input", _MISSING_PASSWORD);
|
|
155
|
+
return { bytes: Buffer.from(pw, "utf8"), owned: true };
|
|
156
|
+
}
|
|
157
|
+
function _wipePw(owned) {
|
|
158
|
+
if (owned.owned) guard.secret.zeroize(owned.bytes, Pkcs12Error, "pkcs12/bad-input", "the password encoding");
|
|
139
159
|
}
|
|
140
160
|
|
|
141
161
|
// Concatenate copies of `src` to the smallest positive multiple of `blockSize` (>= src length), truncating
|
|
@@ -267,8 +287,10 @@ function _buildBag(bag, opts, depth) {
|
|
|
267
287
|
var kDer = _coerceDer(bag.key, "shroudedKey key");
|
|
268
288
|
try { pkcs8.parse(kDer); } catch (e2) { throw _err("pkcs12/bad-input", "shroudedKey key is not a well-formed PKCS#8 PrivateKeyInfo", e2); }
|
|
269
289
|
var enc = bag.encrypt || {};
|
|
270
|
-
var pw =
|
|
271
|
-
var r
|
|
290
|
+
var pw = _pbePasswordOwned(enc.password != null ? enc.password : opts.password);
|
|
291
|
+
var r;
|
|
292
|
+
try { r = pbes2.pbes2Encrypt(pw.bytes, kDer, _pbeOpts(enc), _err, "pkcs12"); }
|
|
293
|
+
finally { _wipePw(pw); }
|
|
272
294
|
return _safeBag("pkcs8ShroudedKeyBag", b.sequence([r.algId, b.octetString(r.ct)]), bag); // EncryptedPrivateKeyInfo
|
|
273
295
|
}
|
|
274
296
|
case "cert": {
|
|
@@ -376,8 +398,10 @@ async function _buildAuthSafeElement(sc, opts) {
|
|
|
376
398
|
return b.sequence([b.oid(O("data")), b.explicit(0, b.octetString(safeContentsDer))]);
|
|
377
399
|
}
|
|
378
400
|
if (!sc.encrypt || typeof sc.encrypt !== "object") throw _err("pkcs12/bad-input", "safeContents.encrypt must be an object { password? } (RFC 7292 sec. 5.1) -- omit it entirely for a plaintext safe");
|
|
379
|
-
var pw =
|
|
380
|
-
var r
|
|
401
|
+
var pw = _pbePasswordOwned(sc.encrypt.password != null ? sc.encrypt.password : opts.password);
|
|
402
|
+
var r;
|
|
403
|
+
try { r = pbes2.pbes2Encrypt(pw.bytes, safeContentsDer, _pbeOpts(sc.encrypt), _err, "pkcs12"); }
|
|
404
|
+
finally { _wipePw(pw); }
|
|
381
405
|
var eci = b.sequence([b.oid(O("data")), r.algId, b.contextPrimitive(0, r.ct)]); // EncryptedContentInfo, [0] IMPLICIT ct
|
|
382
406
|
var encData = b.sequence([b.integer(0n), eci]); // EncryptedData { version 0, eci }
|
|
383
407
|
return b.sequence([b.oid(O("encryptedData")), b.explicit(0, encData)]);
|
|
@@ -420,7 +444,10 @@ async function _buildMacData(macOpts, sharedPassword, authSafeDer) {
|
|
|
420
444
|
var iter2 = _assertMacIter(macOpts.iterations == null ? DEFAULT_PBMAC1_ITER : macOpts.iterations, C.LIMITS.PBKDF2_MAX_ITERATIONS);
|
|
421
445
|
var keyLen = macOpts.keyLength != null ? macOpts.keyLength : prf.keyLen;
|
|
422
446
|
if (typeof keyLen !== "number" || !Number.isInteger(keyLen) || keyLen < 20 || keyLen > MAX_PBMAC1_KEYLEN) throw _err("pkcs12/bad-input", "PBMAC1 keyLength must be an integer in [20, " + MAX_PBMAC1_KEYLEN + "] (RFC 9579 sec. 9)");
|
|
423
|
-
var
|
|
447
|
+
var macPw2 = _pbePasswordOwned(password);
|
|
448
|
+
var mac;
|
|
449
|
+
try { mac = await pbes2.pbmac1(macPw2.bytes, salt, iter2, keyLen, prf.wc, prf.wc, authSafeDer); } // PBKDF2 -> UTF-8; prf == messageAuthScheme on build
|
|
450
|
+
finally { _wipePw(macPw2); }
|
|
424
451
|
var desc = { salt: salt, iterationCount: iter2, keyLength: keyLen, prfName: prf.prfName, macName: prf.prfName };
|
|
425
452
|
var digestInfo2 = b.sequence([pbes2.pbmac1AlgId(desc), b.octetString(mac)]);
|
|
426
453
|
// MacData.macSalt + iterations are ignored on a PBMAC1 verify but MUST be present + non-1 (RFC 9579 4c/4d).
|
|
@@ -498,7 +525,18 @@ function _normalizeSpec(spec, opts) {
|
|
|
498
525
|
*/
|
|
499
526
|
async function build(spec, opts) {
|
|
500
527
|
opts = opts || {};
|
|
501
|
-
|
|
528
|
+
// The integrity mode is checked against the permitted set rather than compared to one literal.
|
|
529
|
+
// Compared, any other spelling reads as "not public-key" and silently selects password integrity,
|
|
530
|
+
// dropping the signer with it: a caller who wrote "publicKey" gets a MAC where they asked for a
|
|
531
|
+
// signature, and nothing in the store or the call says which they got.
|
|
532
|
+
if (opts.integrity != null) {
|
|
533
|
+
if (typeof opts.integrity !== "object" || Array.isArray(opts.integrity)) throw _err("pkcs12/bad-input", "opts.integrity must be an object { mode, signer|signers, ... }");
|
|
534
|
+
guard.identifier.assertKnownKeys(opts.integrity, _INTEGRITY_OPTS, _err, "pkcs12/bad-input", "opts.integrity has an unknown option ");
|
|
535
|
+
if (opts.integrity.mode !== "public-key") {
|
|
536
|
+
throw _err("pkcs12/bad-integrity-mode", "opts.integrity.mode must be \"public-key\" (the only mode it selects); omit opts.integrity for password integrity, got " + JSON.stringify(opts.integrity.mode));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
var pubKey = opts.integrity != null;
|
|
502
540
|
// RFC 7292 sec. 4: public-key integrity OMITS MacData entirely -- a caller combining opts.mac with it is a
|
|
503
541
|
// config-time reject (the self-check re-parse would otherwise fail the coherence rule anyway).
|
|
504
542
|
if (pubKey && opts.mac != null && opts.mac !== false) throw _err("pkcs12/bad-integrity-mode", "public-key integrity has no MacData -- do not combine opts.mac with opts.integrity.mode 'public-key' (RFC 7292 sec. 4)");
|
|
@@ -590,7 +628,9 @@ async function _verifyMacOfStore(m, password, opts) {
|
|
|
590
628
|
// downgraded store cannot pass under a weak MAC even though the algorithm identifiers parse.
|
|
591
629
|
if (prfWc === "SHA-1" || macWc === "SHA-1") throw _err("pkcs12/unsupported-algorithm", "PBMAC1 with a <= 160-bit digest (SHA-1) is refused (RFC 9579 sec. 5/7)");
|
|
592
630
|
_capWork(kdf.iterationCount, kdf.salt, opts, kdf.keyLength, C.LIMITS.PBKDF2_MAX_ITERATIONS);
|
|
593
|
-
|
|
631
|
+
var vPw = _pbePasswordOwned(password);
|
|
632
|
+
try { computed = await pbes2.pbmac1(vPw.bytes, kdf.salt, kdf.iterationCount, kdf.keyLength, prfWc, macWc, m.macedBytes); } // PBKDF2 -> UTF-8
|
|
633
|
+
finally { _wipePw(vPw); }
|
|
594
634
|
}
|
|
595
635
|
return computed.length === expected.length && guard.crypto.constantTimeEqual(computed, expected);
|
|
596
636
|
}
|
|
@@ -687,7 +727,7 @@ async function open(pfx, password, opts) {
|
|
|
687
727
|
// wrong bag password fails at the first encrypted bag as the uniform pkcs12/decrypt-failed.
|
|
688
728
|
var out = { integrityMode: m.integrityMode, macVerified: macVerified, signers: signers, keys: [], certs: [], crls: [], secrets: [] };
|
|
689
729
|
var i;
|
|
690
|
-
var kdfBudget = { rounds:
|
|
730
|
+
var kdfBudget = { rounds: KDF_MAX_ROUNDS }; // aggregate KDF work budget for this whole open(), both schemes
|
|
691
731
|
for (i = 0; i < m.safeBags.length; i++) _openBag(m.safeBags[i], password, opts, out, 0, kdfBudget);
|
|
692
732
|
for (i = 0; i < m.encryptedSafes.length; i++) await _openEncryptedSafe(m.encryptedSafes[i], password, opts, out, 0, kdfBudget);
|
|
693
733
|
if (opts.keys === "crypto") {
|
|
@@ -801,7 +841,15 @@ function _decryptLegacyPbe(ea, ct, password, opts, budget) {
|
|
|
801
841
|
// Decrypt a PBES2 (RFC 8018) or legacy-PBE (RFC 7292 App. C) bag/safe -- dispatch on the encryptionAlgorithm
|
|
802
842
|
// OID. PBES2 uses the UTF-8 password (the pinned interop convention); legacy PBE uses the App. B.1 BMPString.
|
|
803
843
|
function _decryptBag(ea, ct, password, opts, budget) {
|
|
804
|
-
|
|
844
|
+
// BOTH arms charge the one shared budget. Charging only the legacy arm left the modern one --
|
|
845
|
+
// the arm every current producer emits -- able to reset its per-bag cap on every bag, so a store
|
|
846
|
+
// that repeats a costly PBES2 bag up to the parser's element limit multiplied the cap by that
|
|
847
|
+
// limit in blocking pbkdf2Sync work.
|
|
848
|
+
if (ea.oid === O("pbes2")) {
|
|
849
|
+
var pw = _pbePasswordOwned(password);
|
|
850
|
+
try { return pbes2.pbes2Decrypt(pw.bytes, ea.parameters, ct, opts, _err, "pkcs12", budget); }
|
|
851
|
+
finally { _wipePw(pw); }
|
|
852
|
+
}
|
|
805
853
|
return _decryptLegacyPbe(ea, ct, password, opts, budget);
|
|
806
854
|
}
|
|
807
855
|
|
package/lib/schema-cms.js
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
var asn1 = require("./asn1-der");
|
|
48
48
|
var schema = require("./schema-engine");
|
|
49
49
|
var pkix = require("./schema-pkix");
|
|
50
|
+
var guard = require("./guard-all");
|
|
50
51
|
var oid = require("./oid");
|
|
51
52
|
var frameworkError = require("./framework-error");
|
|
52
53
|
var schemaX509 = require("./schema-x509");
|
|
@@ -1150,7 +1151,15 @@ var CONTENT_INFO = schema.seq([
|
|
|
1150
1151
|
* cms.signerInfos[0].sid.serialNumberHex; // -> "0a1b"
|
|
1151
1152
|
* cms.encapContentInfo.eContent; // -> Buffer | null (detached)
|
|
1152
1153
|
*/
|
|
1153
|
-
|
|
1154
|
+
// Recording, for the reason the certificate and CRL parsers are: a SignedData is one or more
|
|
1155
|
+
// signatures over byte ranges, and a parsed one presents those ranges (`signedAttrsBytes`, the
|
|
1156
|
+
// encapsulated `eContent`), the signatures, and the certificates that verify them as separate
|
|
1157
|
+
// properties of one object. Keep a genuine signer's `signature` and `signedAttrsBytes` and replace
|
|
1158
|
+
// the `eContent` beside them, and every part of the check still passes for content that signer never
|
|
1159
|
+
// signed -- which is exactly the forgery pki.cms.verify's own block claims to defend. The verdict
|
|
1160
|
+
// verbs re-derive from what is recorded here, so the object a caller passes names bytes rather than
|
|
1161
|
+
// asserting facts.
|
|
1162
|
+
var parse = pkix.makeRecordingParser({ pemLabel: "CMS", PemError: PemError, ErrorClass: CmsError, prefix: "cms", what: "CMS ContentInfo", topSchema: CONTENT_INFO, ns: NS }, "cms");
|
|
1154
1163
|
|
|
1155
1164
|
/**
|
|
1156
1165
|
* @primitive pki.schema.cms.pemDecode
|
|
@@ -1220,7 +1229,13 @@ function walkEnvelopedData(node) { return schema.walk(ENVELOPED_DATA, node, NS).
|
|
|
1220
1229
|
// (an RFC 7292 PFX authSafe or encrypted safe, whose wire encoding may be BER
|
|
1221
1230
|
// that the strict `parse` entry would refuse). Same contract as
|
|
1222
1231
|
// walkEnvelopedData: the node is the bare structure, typed cms/* on rejection.
|
|
1223
|
-
|
|
1232
|
+
// Records provenance, like parse: the structure a consumer hands to pki.cms.verify must be
|
|
1233
|
+
// re-derivable from the bytes it was walked from, and a PFX authSafe reaches verify this way. The
|
|
1234
|
+
// walker's own BER-tolerant decode is what replays it -- the strict `parse` entry would refuse the
|
|
1235
|
+
// indefinite-length encoding real stores carry.
|
|
1236
|
+
var walkSignedData = guard.parsed.recordingWalker("cms", function (node) {
|
|
1237
|
+
return schema.walk(SIGNED_DATA, node, NS).result;
|
|
1238
|
+
}, function (der) { return asn1.decode(der, { ber: true }); });
|
|
1224
1239
|
function walkEncryptedData(node) { return schema.walk(ENCRYPTED_DATA, node, NS).result; }
|
|
1225
1240
|
// Validate + surface one countersignature value (RFC 5652 sec. 11.4, Countersignature ::=
|
|
1226
1241
|
// SignerInfo) into the same parsed shape parse() gives a top-level SignerInfo -- so pki.cms.verify
|
package/lib/sigstore.js
CHANGED
|
@@ -522,6 +522,9 @@ var IDENTITY_FIELDS = ["san", "issuer", "sourceRepositoryURI"];
|
|
|
522
522
|
// The guard tests membership with hasOwnProperty, so the permitted set is a lookup object --
|
|
523
523
|
// an array would treat "0"/"1" as the known keys and reject every real field name.
|
|
524
524
|
var IDENTITY_KEYS = { san: 1, issuer: 1, sourceRepositoryURI: 1 };
|
|
525
|
+
// Every option pki.sigstore.verifyBundle reads. Adding one here is the only way to make it
|
|
526
|
+
// accepted, so a capability cannot arrive with its option silently ignored at this boundary.
|
|
527
|
+
var _VERIFY_BUNDLE_OPTS = { fulcioRoots: 1, rekorKeys: 1, identity: 1, predicateType: 1, time: 1 };
|
|
525
528
|
|
|
526
529
|
// Returns WHICH fields were actually compared. A bundle verifies its own signature and log
|
|
527
530
|
// inclusion whoever signed it -- Fulcio issues to anyone who completes an OIDC flow -- so
|
|
@@ -633,6 +636,13 @@ async function verifyBundle(bundle, opts) {
|
|
|
633
636
|
throw new TypeError("verifyBundle: bundle must be an object, JSON string, or Buffer");
|
|
634
637
|
}
|
|
635
638
|
opts = opts || {};
|
|
639
|
+
// An unrecognized option is refused, not swallowed. The identity policy one level down already
|
|
640
|
+
// closes this door for exactly the reason it needs closing here too: cosign spells the signer pin
|
|
641
|
+
// `certificateIdentity`, and a swallowed spelling checks nothing under a name the operator
|
|
642
|
+
// believes pins the signer. At the top level the same slip loses the SLSA predicate pin as well,
|
|
643
|
+
// and `predicateType` has no `identityChecked`-style field, so nothing in the verdict reveals
|
|
644
|
+
// that the pin never ran.
|
|
645
|
+
guard.identifier.assertKnownKeys(opts, _VERIFY_BUNDLE_OPTS, _err, "sigstore/bad-input", "pki.sigstore.verifyBundle has an unknown option ");
|
|
636
646
|
var b = parseBundle(bundle);
|
|
637
647
|
var vm = b.verificationMaterial;
|
|
638
648
|
var env = b.dsseEnvelope;
|
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/package.json
CHANGED
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
|
]
|