@blamejs/pki 0.6.6 → 0.6.7
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 +14 -0
- package/README.md +1 -1
- package/index.js +1 -1
- package/lib/acme.js +6 -2
- package/lib/est.js +6 -2
- package/lib/http-transport.js +5 -1
- package/lib/scep.js +503 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ 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.6.7 — 2026-08-31
|
|
8
|
+
|
|
9
|
+
pki.scep gains the SCEP HTTP client verbs (RFC 8894): getCACaps, getCACert, enroll, and renew drive a certificate enrollment against a SCEP CA over the shipped pkiMessage codec.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.scep.getCACaps(baseUrl, opts) queries a SCEP CA's advertised capabilities (RFC 8894 sec. 3.5) and returns the parsed keyword set; pki.scep.parseCapabilities(text) exposes the parser directly. The response is unauthenticated (sec. 7.5), so the verb reports what the CA claims without downgrading on it; pass expectSCEPStandard or requireStrongProfile to fail closed when the strong profile is absent.
|
|
14
|
+
- pki.scep.getCACert(baseUrl, opts) retrieves the CA certificate, either a single DER certificate or a certs-only chain (RFC 8894 sec. 4.2). Pass an out-of-band expectedFingerprint (a hex string or Buffer, SHA-256 by default) and a returned certificate must match it or the response is refused (RFC 8894 sec. 2.2); omitting it returns the certificates for the caller to authenticate.
|
|
15
|
+
- pki.scep.enroll(baseUrl, opts) and pki.scep.renew(baseUrl, opts) POST a PKCSReq or RenewalReq to ?operation=PKIOperation and read the CertRep. The response signature is authenticated against the CA certificate and its recipientNonce must echo the request's senderNonce; on SUCCESS the issued certificate is selected out of the response by SubjectPublicKeyInfo match, a FAILURE throws scep/enrollment-failed carrying the CA's failInfo, and a PENDING returns { status: "PENDING", transactionId } to retry. The content key is transported under RSAES-OAEP, so the CA certificate must be an RSA keyEncipherment certificate.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- The EST, SCEP, and ACME client verbs override the mTLS client certificate, key, and pinned servername on a cross-origin redirect rather than omitting them. A transport constructed with a default client credential (for example pki.transport.https({ tls: { cert, key } })) no longer presents that credential to the redirected origin.
|
|
20
|
+
|
|
7
21
|
## v0.6.6 — 2026-08-31
|
|
8
22
|
|
|
9
23
|
pki.kem establishes a shared secret with composite ML-KEM (draft-ietf-lamps-pq-composite-kem): a post-quantum ML-KEM hybridized with a traditional RSA-OAEP, ECDH, X25519, or X448, so the secret holds if either component is later broken.
|
package/README.md
CHANGED
|
@@ -222,7 +222,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
222
222
|
| `pki.schema.cmp` | CMP messages (RFC 9810): the header (version, sender and recipient including the anonymous NULL-DN, nonces, transaction id, general info), the 27-arm body (certificate requests via the CRMF parser, an encrypted certificate's EnvelopedData via CMS, response / revocation / confirmation / error / support / polling arms structural, the rest raw), and the exact `headerBytes` and `bodyBytes` slices an external verifier reconstructs the protected part from. The CMP-before-OCSP dispatch order is enforced Fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
223
223
|
| `pki.schema.csrattrs` | EST CSR Attributes (`CsrAttrs`, RFC 8951 §3.5 / RFC 9908) — the `AttrOrOID` items a server sends to shape an enrollment: bare OIDs, attributes with raw values, and decoded views of the RFC 9908 meaningful types (extension requests, EC and RSA key-type conventions, the certification-request-info template). Unknown types are surfaced raw; structure and the RFC 9908 semantic MUSTs are fail-closed — `parse` |
|
|
224
224
|
| `pki.est` | Enrollment over Secure Transport (RFC 7030 / 8951 / 9908 / 7616). The client verbs `cacerts`, `simpleenroll`, `simplereenroll`, `serverkeygen`, `csrattrs`, and `fullcmc` drive the RFC 7030 flow over `pki.transport` (inject your own via `opts.transport`, or take the fail-closed default): HTTPS only, an explicit trust anchor required, same-origin redirects followed while a downgrade or loop is refused, a 202 Retry-After surfaced but never slept, HTTP Basic or Digest (RFC 7616, SHA-256 / SHA-512-256; MD5 and no-qop refused by default) answered only after the server is authenticated, and the issued certificate chosen by public-key match. `serverkeygen` requests a server-generated key, cleartext or an opaque CMS EnvelopedData, with encryption bound to the CSR's key-identifier attribute over a confidentiality-bearing cipher. `csrattrs` fetches the CA's RFC 9908 attributes policy. `fullcmc` (§4.3) carries a CMC Full PKI Request and reduces the CA's answer through `pki.cmc.verify` to one terminal outcome, refusing a response that fails to echo the transaction and nonce the request carried, or that covers a key the request never asked for; a certs-only reply to a bound request is refused rather than read as an issuance. Under the verbs sit the transport-agnostic codecs they compose: the RFC 8951 base64 transfer codec (which ignores Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only and serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `transferDecode`/`transferEncode`, `parseCertsOnly`, `splitMultipartMixed`, `parseServerKeygenResponse`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
|
|
225
|
-
| `pki.scep` | SCEP pkiMessage codec (RFC 8894), the client side of Simple Certificate Enrollment. `build(spec)` assembles a `PKCSReq` or `RenewalReq` request: it verifies the PKCS#10 certification request's own proof-of-possession, encrypts it to the CA as the inner EnvelopedData (`pkcsPKIEnvelope`, AES-128-CBC), and signs that under the transaction attributes RFC 8894 §3.2 defines, a messageType, a caller-unique transactionId, and a 16-byte senderNonce. The recipient CA certificate must assert `keyEncipherment`. `parse(bytes, opts?)` reads a `PKCSReq`, `RenewalReq`, or `CertRep` back (the `CertPoll`, `GetCert`, and `GetCRL` client queries are refused): it verifies the outer signature first and refuses a message whose signature does not check, so the transaction attributes it returns come only from a verified signer, never alongside a false verdict. Because a valid signature only proves self-consistency with the embedded certificate, pass `opts.signerCert` to authenticate a CA response against the CA certificate you hold (a public-key mismatch is refused, `signerAuthenticated` reports the result); a caller that omits it gets a crypto-only verdict and must authenticate the surfaced `signerCert` itself. It maps the messageType, pkiStatus, failInfo, and the nonces from their RFC 8894 enumerants and refuses an unknown one; given `recipientKey` it decrypts the `pkcsPKIEnvelope` to the messageData, and given `expectedSenderNonce` it refuses a response whose recipientNonce does not echo it. It reads a CA's CertRep in full. Issuing a CertRep
|
|
225
|
+
| `pki.scep` | SCEP pkiMessage codec (RFC 8894), the client side of Simple Certificate Enrollment. `build(spec)` assembles a `PKCSReq` or `RenewalReq` request: it verifies the PKCS#10 certification request's own proof-of-possession, encrypts it to the CA as the inner EnvelopedData (`pkcsPKIEnvelope`, AES-128-CBC), and signs that under the transaction attributes RFC 8894 §3.2 defines, a messageType, a caller-unique transactionId, and a 16-byte senderNonce. The recipient CA certificate must assert `keyEncipherment`. `parse(bytes, opts?)` reads a `PKCSReq`, `RenewalReq`, or `CertRep` back (the `CertPoll`, `GetCert`, and `GetCRL` client queries are refused): it verifies the outer signature first and refuses a message whose signature does not check, so the transaction attributes it returns come only from a verified signer, never alongside a false verdict. Because a valid signature only proves self-consistency with the embedded certificate, pass `opts.signerCert` to authenticate a CA response against the CA certificate you hold (a public-key mismatch is refused, `signerAuthenticated` reports the result); a caller that omits it gets a crypto-only verdict and must authenticate the surfaced `signerCert` itself. It maps the messageType, pkiStatus, failInfo, and the nonces from their RFC 8894 enumerants and refuses an unknown one; given `recipientKey` it decrypts the `pkcsPKIEnvelope` to the messageData, and given `expectedSenderNonce` it refuses a response whose recipientNonce does not echo it. It reads a CA's CertRep in full. `getCACaps(baseUrl, opts?)`, `getCACert(baseUrl, opts?)`, `enroll(baseUrl, opts)`, and `renew(baseUrl, opts)` carry that codec to a live CA over HTTP (RFC 8894 §4) through an injectable transport (`pki.transport.https` by default): `getCACaps` reports the CA's advertised capabilities and never lowers the client's algorithm choice on the unauthenticated response, `getCACert` pins the CA certificate to an out-of-band `expectedFingerprint` a returned certificate must match, and `enroll` and `renew` POST a PKCSReq or RenewalReq, authenticate the CertRep against the CA certificate and its nonce echo, select the issued certificate by public-key match, throw on a FAILURE with the CA's failInfo, and return a PENDING to retry. Issuing a CertRep, `GetNextCACert`, and automatic polling are not yet built — `build`, `parse`, `getCACaps`, `getCACert`, `enroll`, `renew` |
|
|
226
226
|
| `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive. `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body, tls }`, where `tls` carries the negotiated `protocol`, `cipher`, and raw `peerCertificate`. This is the toolkit's only socket choke point: an explicit trust anchor (or an opt-in to the system store) is required, `rejectUnauthorized` is always on, TLS is floored at 1.2, the response body is capped while it streams, and a stalled socket times out. The EST, ACME, and CMP clients reuse it verbatim. If you inject your own transport, return `tls` too: `pki.est.serverkeygen` asserts the negotiated cipher can protect the delivered private key, and a transport that reports no cipher is trusted rather than refused, so omitting the field silently skips that check — `https` |
|
|
227
227
|
| `pki.jose` | Flattened JWS (RFC 7515) and JWK thumbprints (RFC 7638). `sign` and `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required and forbidden header rules as data. `base64url` is the strict RFC 4648 §5 codec, rejecting padding, non-alphabet characters, and non-canonical trailing bits. `parseJson` is a bounded reader that refuses duplicate members at any depth. `thumbprint` is the RFC 7638 / 8037 / 9964 canonical digest. The algorithm registry binds each `alg` to its key type (ES/RS/PS/EdDSA/ML-DSA), leaving no code path for `alg:none`, an RS256→HS256 key confusion, or an all-zero ECDSA signature; `assertPublicJwk` refuses a JWK carrying private material, so an exported private key is never published. `opts.key` names the key a message must be signed under and governs: where the profile also permits an embedded header `jwk`, the two must be the same key — compared as RFC 7638 thumbprints, so member order cannot make equal keys differ — and a disagreement is refused rather than resolved in the message's favor. `keySource` reports which key answered, since a signature checked against a key you named is a different claim from one checked against the key the message carried — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
|
|
228
228
|
| `pki.acme` | ACME (RFC 8555 / 8737 / 8738 / 9773). `client(directoryUrl, opts)` is a stateful client driving a live CA directory over `pki.transport`: `newAccount`, `newOrder`, `newAuthz`, `getOrder`, `getAuthorization`, `getChallenge`, `respondToChallenge`, `finalize`, `pollOrder`, `pollAuthorization`, and `downloadCertificate` walk the issuance flow, with `newAuthz` pre-authorizing a single identifier (§7.4.1) and `downloadCertificate` binding the issued certificate to the order — its key, its identifiers, or both; at least one is required by default and the result reports `boundToKey` / `boundToIdentifiers` — before choosing among alternate chains (`Link rel="alternate"`, §7.4.2, via a `selectChain` predicate bounded by `maxAlternates`). `revokeCert` (account-key or certificate-key signed), `keyChange`, `updateAccount` (change the account contacts, §7.3.2), `listOrders` (the account's orders list, following the §7.1.2.1 `Link rel="next"` pagination bounded by a page cap and confined to the orders origin), `deactivateAccount`, `deactivateAuthorization`, `renewalInfo` (ARI), and `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision) complete the lifecycle. Every URL is HTTPS only, an explicit trust anchor is required, each request carries a fresh single-use nonce with a bounded badNonce retry, reads are POST-as-GET, polling is bounded and sleeps on a Retry-After via an injectable sleeper capped by a poll count and a total-wait budget, and every response body is size-capped. The transport is injectable via `opts.transport`. Over the message layer it composes resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, the request builders (newAccount with External Account Binding, newOrder with `replaces`, finalize with a CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns and ip identifier validators, and the ARI certID with serial sign-padding preserved — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
package/index.js
CHANGED
|
@@ -171,7 +171,7 @@ module.exports = {
|
|
|
171
171
|
// splitter, certs-only + serverkeygen validators over CMS, the enroll-attribute
|
|
172
172
|
// builders, the HTTP response classifier). est opens no socket; fail-closed.
|
|
173
173
|
est: est,
|
|
174
|
-
scep: { build: scep.build, parse: scep.parse },
|
|
174
|
+
scep: { build: scep.build, parse: scep.parse, getCACaps: scep.getCACaps, getCACert: scep.getCACert, enroll: scep.enroll, renew: scep.renew, parseCapabilities: scep.parseCapabilities },
|
|
175
175
|
// `transport` is the shared fail-closed node:https transport the enrollment clients
|
|
176
176
|
// drive -- pki.transport.https(defaults) returns a transport(request) -> {status,
|
|
177
177
|
// headers, body}. The toolkit's sole socket choke point: explicit trust anchors,
|
package/lib/acme.js
CHANGED
|
@@ -2346,9 +2346,13 @@ function client(directoryUrl, opts) {
|
|
|
2346
2346
|
// pin, or a stricter host check) that node re-evaluates against the actual host, so a pin keeps applying
|
|
2347
2347
|
// and a host-pinned check fails the wrong host closed; dropping it would accept the cross-origin host
|
|
2348
2348
|
// under only the default hostname validation, bypassing the pin.
|
|
2349
|
-
if (new URL(url).origin !== trustedOrigin
|
|
2349
|
+
if (new URL(url).origin !== trustedOrigin) {
|
|
2350
|
+
// Every cross-origin hop overrides the client identity with an explicit null, unconditionally: an
|
|
2351
|
+
// omitted field falls back to the transport's own configured defaults (http-transport resolves
|
|
2352
|
+
// reqTls.cert !== undefined ? reqTls.cert : tlsDefaults.cert), so the override must fire even when the
|
|
2353
|
+
// request-level tls carries no credential, or a transport default would still reach the new origin.
|
|
2350
2354
|
t = Object.assign({}, t);
|
|
2351
|
-
|
|
2355
|
+
t.cert = null; t.key = null; t.servername = null;
|
|
2352
2356
|
}
|
|
2353
2357
|
return t;
|
|
2354
2358
|
}
|
package/lib/est.js
CHANGED
|
@@ -1100,9 +1100,13 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
|
1100
1100
|
// restores its identity rather than permanently losing the SNI at the first cross-origin boundary.
|
|
1101
1101
|
function _tlsFor(u) {
|
|
1102
1102
|
var t = budgets.tls;
|
|
1103
|
-
if (t && u.origin !== initialOrigin
|
|
1103
|
+
if (t && u.origin !== initialOrigin) {
|
|
1104
|
+
// Every cross-origin hop overrides the client identity with an explicit null, unconditionally: an
|
|
1105
|
+
// omitted field falls back to the transport's own configured defaults (http-transport resolves
|
|
1106
|
+
// reqTls.cert !== undefined ? reqTls.cert : tlsDefaults.cert), so the override must fire even when the
|
|
1107
|
+
// request-level tls carries no credential, or a transport default would still reach the new origin.
|
|
1104
1108
|
t = Object.assign({}, t);
|
|
1105
|
-
|
|
1109
|
+
t.cert = null; t.key = null; t.servername = null;
|
|
1106
1110
|
}
|
|
1107
1111
|
return t;
|
|
1108
1112
|
}
|
package/lib/http-transport.js
CHANGED
|
@@ -351,7 +351,11 @@ function httpsTransport(defaults) {
|
|
|
351
351
|
// SNI (and RFC 6125 identity) uses a hostname; node forbids a servername that is an IP
|
|
352
352
|
// literal, so it is omitted for an IP host (node then matches the IP against the cert's
|
|
353
353
|
// IP SANs). A caller connecting by hostname gets SNI + name verification by default.
|
|
354
|
-
|
|
354
|
+
// The per-request servername falls back to the factory default only when it is ABSENT
|
|
355
|
+
// (as cert/key do); an explicit value, INCLUDING null, overrides the default, so a caller
|
|
356
|
+
// that sets servername to null on a cross-origin hop suppresses a configured default SNI
|
|
357
|
+
// rather than falsy-coalescing back to it.
|
|
358
|
+
var sni = (reqTls.servername !== undefined ? reqTls.servername : tlsDefaults.servername) || host;
|
|
355
359
|
if (sni && !nodeNet.isIP(sni)) options.servername = sni;
|
|
356
360
|
// Build node's `ca`: the explicit anchors (DER wrapped as PEM) and, when useSystemStore is opted
|
|
357
361
|
// in, the OS system store + bundled roots. Node's `ca` REPLACES the default set, so both sources
|
package/lib/scep.js
CHANGED
|
@@ -52,6 +52,7 @@ var schemaCrl = require("./schema-crl");
|
|
|
52
52
|
var pkix = require("./schema-pkix");
|
|
53
53
|
var guard = require("./guard-all");
|
|
54
54
|
var C = require("./constants");
|
|
55
|
+
var httpTransport = require("./http-transport"); // the injectable https transport for the client verbs (sec. 4)
|
|
55
56
|
var nodeCrypto = require("node:crypto");
|
|
56
57
|
|
|
57
58
|
function _err(code, msg, cause) { return new ScepError(code, msg, cause); }
|
|
@@ -553,9 +554,511 @@ async function parse(bytes, opts) {
|
|
|
553
554
|
}
|
|
554
555
|
var _PARSE_KEYS = { recipientKey: 1, expectedSenderNonce: 1, signerCert: 1 };
|
|
555
556
|
|
|
557
|
+
// ---- HTTP client (RFC 8894 sec. 4) -------------------------------------------
|
|
558
|
+
//
|
|
559
|
+
// The client verbs compose the shipped pkiMessage codec (build / parse) over an injectable transport
|
|
560
|
+
// (pki.transport.https by default; tests inject a fake). SCEP's message security is the CMS layer, not
|
|
561
|
+
// the transport (RFC 8894 sec. 2.8), so https is not forced at the URL level; a caller with a plain-HTTP
|
|
562
|
+
// CA injects its own transport. This release ships the mandatory-to-implement set (GetCACaps, GetCACert,
|
|
563
|
+
// PKCSReq via HTTP POST, AES-128-CBC + SHA-256, sec. 2.9) plus RenewalReq. Deferred, each re-opening
|
|
564
|
+
// under its named condition: GetNextCACert (the rollover-certificate authentication model, sec. 4.7);
|
|
565
|
+
// auto-poll / GetCert / GetCRL (their CertPoll / IssuerAndSerialNumber messageData builders are not in
|
|
566
|
+
// this codec); GET-carried PKIOperation (POST is mandatory, sec. 4.3); and HTTP auth (a CA behind an
|
|
567
|
+
// authenticating proxy).
|
|
568
|
+
var DEFAULT_TIMEOUT = C.TIME.seconds(30);
|
|
569
|
+
var MAX_TIMEOUT = C.TIME.seconds(600);
|
|
570
|
+
|
|
571
|
+
// The SCEP media types (RFC 8894 sec. 4.2 / 4.6). A 200 response MUST carry the operation's media type;
|
|
572
|
+
// a proxy error page or a wrong type is refused (fail-closed).
|
|
573
|
+
var CT_CA_CERT = "application/x-x509-ca-cert"; // GetCACert, a single DER certificate
|
|
574
|
+
var CT_CA_RA_CERT = "application/x-x509-ca-ra-cert"; // GetCACert, a certs-only CMS chain
|
|
575
|
+
var CT_PKI_MESSAGE = "application/x-pki-message"; // PKIOperation, a pkiMessage
|
|
576
|
+
var CT_PLAIN = "text/plain"; // GetCACaps, a newline-separated capability list
|
|
577
|
+
|
|
578
|
+
function _isWs(c) { return c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d || c === 0x0b || c === 0x0c; }
|
|
579
|
+
function _foldLower(s) { var o = "", i, n = s.length; for (i = 0; i < n; i++) { var c = s.charCodeAt(i); o += String.fromCharCode(c >= 65 && c <= 90 ? c + 32 : c); } return o; }
|
|
580
|
+
function _foldUpper(s) { var o = "", i, n = s.length; for (i = 0; i < n; i++) { var c = s.charCodeAt(i); o += String.fromCharCode(c >= 97 && c <= 122 ? c - 32 : c); } return o; }
|
|
581
|
+
function _trim(s) { var a = 0, e = s.length; while (a < e && _isWs(s.charCodeAt(a))) a += 1; while (e > a && _isWs(s.charCodeAt(e - 1))) e -= 1; return s.slice(a, e); }
|
|
582
|
+
|
|
583
|
+
// A case-insensitive single-header read. A duplicated header is refused: an injected transport promises
|
|
584
|
+
// only { status, headers, body }, and a smuggled duplicate must never be silently coalesced.
|
|
585
|
+
function _header(headers, name) {
|
|
586
|
+
if (!headers || typeof headers !== "object") return null;
|
|
587
|
+
var found = null, seen = 0, k;
|
|
588
|
+
for (k in headers) {
|
|
589
|
+
if (!Object.prototype.hasOwnProperty.call(headers, k)) continue;
|
|
590
|
+
if (_foldLower(String(k)) === name) { found = headers[k]; seen += 1; }
|
|
591
|
+
}
|
|
592
|
+
if (seen > 1) throw _err("scep/bad-response", "the response carried more than one " + name + " header");
|
|
593
|
+
return found == null ? null : String(found);
|
|
594
|
+
}
|
|
595
|
+
// The bare media type of a Content-Type (strip parameters, lower-case), by character scan (no regex).
|
|
596
|
+
function _mediaType(ct) {
|
|
597
|
+
if (ct == null) return "";
|
|
598
|
+
var s = String(ct), semi = s.indexOf(";");
|
|
599
|
+
return _foldLower(_trim(semi === -1 ? s : s.slice(0, semi)));
|
|
600
|
+
}
|
|
601
|
+
function _bodyBuf(body) {
|
|
602
|
+
if (Buffer.isBuffer(body)) return body;
|
|
603
|
+
if (body == null) return Buffer.alloc(0);
|
|
604
|
+
if (typeof body === "string") return Buffer.from(body, "utf8");
|
|
605
|
+
// A BufferSource (Uint8Array, ArrayBuffer, DataView) from an injected transport: normalize its raw bytes
|
|
606
|
+
// rather than String()-coercing it, which would turn a Uint8Array into its "48,1,..." toString.
|
|
607
|
+
return guard.bytes.snapshotSource(body, ScepError, "scep/bad-response", "the response body");
|
|
608
|
+
}
|
|
609
|
+
// The byte length of a transport response body, read without a full copy so the size cap is enforced before
|
|
610
|
+
// the body is materialized (cap-before-copy): a string is measured as UTF-8, a Buffer or other BufferSource
|
|
611
|
+
// through the byte guard, which reads the length via a captured intrinsic rather than a shadowable accessor.
|
|
612
|
+
function _responseLen(body) {
|
|
613
|
+
if (body == null) return 0;
|
|
614
|
+
if (typeof body === "string") return Buffer.byteLength(body, "utf8");
|
|
615
|
+
if (Buffer.isBuffer(body)) return guard.bytes.lengthOf(body);
|
|
616
|
+
return guard.bytes.lengthOf(guard.bytes.view(body, ScepError, "scep/bad-response", "the response body"));
|
|
617
|
+
}
|
|
618
|
+
// Map opts.tls to the transport request.tls shape. rejectUnauthorized is NOT set -- the transport forces
|
|
619
|
+
// it on for an https connection.
|
|
620
|
+
function _tlsForRequest(opts) {
|
|
621
|
+
var t = opts.tls || {};
|
|
622
|
+
return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
|
|
623
|
+
}
|
|
624
|
+
// Resolve a redirect target: refuse an https->http downgrade (a stripping MITM) and a cross-origin
|
|
625
|
+
// redirect on a non-idempotent method without opt-in; an unparseable Location is scep/bad-url.
|
|
626
|
+
function _redirectTarget(current, location, method, allowCrossOrigin) {
|
|
627
|
+
if (location == null || _trim(String(location)) === "") throw _err("scep/bad-response", "a redirect response carried no Location header");
|
|
628
|
+
var resolved;
|
|
629
|
+
try { resolved = new URL(String(location), current.href); }
|
|
630
|
+
catch (e) { throw _err("scep/bad-url", "a redirect Location did not parse: " + location, e); }
|
|
631
|
+
// SCEP is an HTTP operation (RFC 8894 sec. 4): a redirect target on any other scheme (ftp:, file:, ...)
|
|
632
|
+
// is refused before it reaches the transport, so a CA cannot steer an injected transport off HTTP.
|
|
633
|
+
if (resolved.protocol !== "http:" && resolved.protocol !== "https:") throw _err("scep/bad-url", "a redirect to a non-HTTP scheme is refused: " + resolved.protocol);
|
|
634
|
+
if (current.protocol === "https:" && resolved.protocol !== "https:") throw _err("scep/insecure-redirect", "a redirect from https to " + resolved.protocol + " is refused");
|
|
635
|
+
var safe = method === "GET" || method === "HEAD";
|
|
636
|
+
if (resolved.origin !== current.origin && !safe && !allowCrossOrigin) throw _err("scep/cross-origin-redirect", "a cross-origin redirect on a " + method + " needs opts.allowCrossOriginRedirect");
|
|
637
|
+
return resolved;
|
|
638
|
+
}
|
|
639
|
+
// Build a SCEP operation URL: the caller's base URL with ?operation=<op> appended (RFC 8894 sec. 4.1;
|
|
640
|
+
// SCEPPATH is whatever the base carries, the CA typically ignores it).
|
|
641
|
+
function _scepUrl(baseUrl, operation) {
|
|
642
|
+
var url;
|
|
643
|
+
try { url = new URL(String(baseUrl)); }
|
|
644
|
+
catch (e) { throw _err("scep/bad-url", "the SCEP base URL did not parse: " + String(baseUrl), e); }
|
|
645
|
+
// SCEP is an HTTP operation (RFC 8894 sec. 4): any other scheme (ftp:, file:, ...) is refused before the
|
|
646
|
+
// URL reaches the transport, so an injected transport cannot be steered off HTTP.
|
|
647
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw _err("scep/bad-url", "the SCEP base URL must be http or https, got " + url.protocol);
|
|
648
|
+
if (url.hash) throw _err("scep/bad-url", "the SCEP base URL must not carry a fragment");
|
|
649
|
+
url.searchParams.set("operation", operation);
|
|
650
|
+
return url;
|
|
651
|
+
}
|
|
652
|
+
// The transport request / redirect loop. Returns the terminal transport response ({ status, headers,
|
|
653
|
+
// body }). Digest / Basic auth is not answered (deferred); a 401 is returned to the verb, which throws.
|
|
654
|
+
function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
655
|
+
var redirects = 0, curMethod = method, curBody = body, initialOrigin = url.origin;
|
|
656
|
+
// Snapshot the caller's TLS fields and redirect policy once, at the start, before the first transport call:
|
|
657
|
+
// the redirect loop reruns per hop after each transport promise settles, so a hop must use the identity and
|
|
658
|
+
// policy the initial hop did even if opts (or opts.tls) is replaced while a request is in flight.
|
|
659
|
+
var tlsBase = _tlsForRequest(opts);
|
|
660
|
+
var allowCrossOrigin = !!opts.allowCrossOriginRedirect;
|
|
661
|
+
// The mTLS client identity (cert / key / pinned SNI) is sent ONLY to the origin the caller intended; a
|
|
662
|
+
// cross-origin redirect gets a copy with those stripped, so a CA that redirects to another origin cannot
|
|
663
|
+
// make the client authenticate to it with the caller's client credential. Derived fresh each hop, so a
|
|
664
|
+
// redirect back to the original origin restores it.
|
|
665
|
+
function _hopTls(current) {
|
|
666
|
+
var t = tlsBase;
|
|
667
|
+
if (current.origin !== initialOrigin) {
|
|
668
|
+
// Every cross-origin hop overrides the client identity with an explicit null, unconditionally: an
|
|
669
|
+
// omitted field falls back to the transport's own configured defaults (http-transport resolves
|
|
670
|
+
// reqTls.cert !== undefined ? reqTls.cert : tlsDefaults.cert), so the override must fire even when the
|
|
671
|
+
// request-level tls carries no credential, or a transport default would still reach the new origin.
|
|
672
|
+
t = Object.assign({}, t);
|
|
673
|
+
t.cert = null; t.key = null; t.servername = null;
|
|
674
|
+
}
|
|
675
|
+
return t;
|
|
676
|
+
}
|
|
677
|
+
function step(current) {
|
|
678
|
+
var req = { method: curMethod, url: current.href, headers: headers, tls: _hopTls(current), timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes };
|
|
679
|
+
if (curBody != null) req.body = curBody;
|
|
680
|
+
return transport(req).then(function (res) {
|
|
681
|
+
res = res || {};
|
|
682
|
+
var blen = _responseLen(res.body);
|
|
683
|
+
if (blen > budgets.maxResponseBytes) throw _err("scep/response-too-large", "the response body (" + blen + " bytes) exceeds the " + budgets.maxResponseBytes + "-byte cap");
|
|
684
|
+
var status = res.status;
|
|
685
|
+
if (status >= 300 && status < 400) {
|
|
686
|
+
if (redirects >= budgets.maxRedirects) throw _err("scep/too-many-redirects", "the SCEP request exceeded the " + budgets.maxRedirects + "-redirect cap");
|
|
687
|
+
redirects += 1;
|
|
688
|
+
var target = _redirectTarget(current, _header(res.headers, "location"), curMethod, allowCrossOrigin);
|
|
689
|
+
if (status === 303 && curMethod !== "GET" && curMethod !== "HEAD") { curMethod = "GET"; curBody = null; }
|
|
690
|
+
return step(target);
|
|
691
|
+
}
|
|
692
|
+
return res;
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return step(url);
|
|
696
|
+
}
|
|
697
|
+
function _client(method, url, body, headers, opts) {
|
|
698
|
+
var transport = opts.transport || httpTransport.https({ E: _err, errPrefix: "scep" });
|
|
699
|
+
var budgets = {
|
|
700
|
+
timeout: guard.limits.cap(opts.timeout, "timeout", DEFAULT_TIMEOUT, { E: _err, code: "scep/bad-input", min: 1, max: MAX_TIMEOUT }),
|
|
701
|
+
maxResponseBytes: guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", C.LIMITS.HTTP_MAX_RESPONSE_BYTES, { E: _err, code: "scep/bad-input", min: 1, max: C.LIMITS.HTTP_MAX_RESPONSE_BYTES }),
|
|
702
|
+
maxRedirects: guard.limits.cap(opts.maxRedirects, "maxRedirects", 5, { E: _err, code: "scep/bad-input", min: 0, max: 32 }),
|
|
703
|
+
};
|
|
704
|
+
return _drive(method, url, body, Object.assign({}, headers), opts, transport, budgets);
|
|
705
|
+
}
|
|
706
|
+
// Require a 200 with one of the expected media types; otherwise a typed error. Returns the body Buffer.
|
|
707
|
+
function _requireOk(res, expected, what) {
|
|
708
|
+
var status = res.status;
|
|
709
|
+
if (status !== 200) {
|
|
710
|
+
var text = Buffer.isBuffer(res.body) ? res.body.toString("utf8", 0, 256) : String(res.body == null ? "" : res.body).slice(0, 256);
|
|
711
|
+
throw _err("scep/http-error", "the SCEP " + what + " returned HTTP " + status + (text ? ": " + text : ""));
|
|
712
|
+
}
|
|
713
|
+
if (expected) {
|
|
714
|
+
var ct = _mediaType(_header(res.headers, "content-type"));
|
|
715
|
+
if (expected.indexOf(ct) === -1) throw _err("scep/bad-content-type", "a 200 " + what + " response must carry content-type " + expected.join(" or ") + ", got " + JSON.stringify(ct));
|
|
716
|
+
}
|
|
717
|
+
return _bodyBuf(res.body);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// The GetCACaps keyword set (RFC 8894 sec. 3.5.2, Table 7). Comparison is case-folded; the canonical
|
|
721
|
+
// spelling is stored. An unknown keyword is ignored (sec. 3.5.2). SCEPStandard implies the sec. 2.9 set.
|
|
722
|
+
var CAP_CANON = { AES: "AES", DES3: "DES3", GETNEXTCACERT: "GetNextCACert", POSTPKIOPERATION: "POSTPKIOperation",
|
|
723
|
+
RENEWAL: "Renewal", "SHA-1": "SHA-1", "SHA-256": "SHA-256", "SHA-512": "SHA-512", SCEPSTANDARD: "SCEPStandard" };
|
|
724
|
+
function _splitLines(s) {
|
|
725
|
+
var out = [], start = 0, i, n = s.length;
|
|
726
|
+
for (i = 0; i < n; i++) if (s.charCodeAt(i) === 0x0a) { out.push(s.slice(start, i)); start = i + 1; }
|
|
727
|
+
out.push(s.slice(start));
|
|
728
|
+
return out;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* @primitive pki.scep.parseCapabilities
|
|
733
|
+
* @signature pki.scep.parseCapabilities(text) -> { AES?, "SHA-256"?, POSTPKIOperation?, ... }
|
|
734
|
+
* @since 0.6.7
|
|
735
|
+
* @status stable
|
|
736
|
+
* @spec RFC 8894
|
|
737
|
+
* @related pki.scep.getCACaps
|
|
738
|
+
*
|
|
739
|
+
* Parse a GetCACaps response body (RFC 8894 sec. 3.5.2): one capability keyword per line, CR/LF or LF
|
|
740
|
+
* delimited. The returned object has a `true` property for each recognized keyword (`AES`, `DES3`,
|
|
741
|
+
* `GetNextCACert`, `POSTPKIOperation`, `Renewal`, `SHA-1`, `SHA-256`, `SHA-512`, `SCEPStandard`);
|
|
742
|
+
* comparison is case-insensitive and an unknown keyword is ignored (sec. 3.5.2). `SCEPStandard` implies
|
|
743
|
+
* `AES`, `POSTPKIOperation`, and `SHA-256` (the sec. 2.9 mandatory profile), which are set on the result.
|
|
744
|
+
* An empty body yields an empty set ("none supported", sec. 3.5.1).
|
|
745
|
+
*
|
|
746
|
+
* @example
|
|
747
|
+
* var caps = pki.scep.parseCapabilities("AES\r\nSHA-256\r\nPOSTPKIOperation\r\n");
|
|
748
|
+
* caps.POSTPKIOperation === true;
|
|
749
|
+
*/
|
|
750
|
+
function parseCapabilities(text) {
|
|
751
|
+
var raw = Buffer.isBuffer(text) ? text.toString("latin1") : String(text == null ? "" : text);
|
|
752
|
+
var set = {}, lines = _splitLines(raw), i;
|
|
753
|
+
for (i = 0; i < lines.length; i++) {
|
|
754
|
+
var kw = _foldUpper(_trim(lines[i]));
|
|
755
|
+
if (kw !== "" && CAP_CANON[kw]) set[CAP_CANON[kw]] = true;
|
|
756
|
+
}
|
|
757
|
+
if (set.SCEPStandard) { set.AES = true; set.POSTPKIOperation = true; set["SHA-256"] = true; }
|
|
758
|
+
return set;
|
|
759
|
+
}
|
|
760
|
+
function _assertStrongProfile(caps, profile) {
|
|
761
|
+
if (profile.expectSCEPStandard && !caps.SCEPStandard) throw _err("scep/weak-profile", "the CA did not advertise SCEPStandard (RFC 8894 sec. 3.5.2); the GetCACaps response is unauthenticated, so its absence is a downgrade signal (sec. 7.5)");
|
|
762
|
+
if (profile.requireStrongProfile && (!caps.AES || !caps["SHA-256"] || !caps.POSTPKIOperation)) throw _err("scep/weak-profile", "the CA did not advertise the strong profile (AES + SHA-256 + POSTPKIOperation)");
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* @primitive pki.scep.getCACaps
|
|
767
|
+
* @signature pki.scep.getCACaps(baseUrl, opts?) -> Promise<capabilities>
|
|
768
|
+
* @since 0.6.7
|
|
769
|
+
* @status stable
|
|
770
|
+
* @spec RFC 8894
|
|
771
|
+
* @related pki.scep.parseCapabilities, pki.scep.enroll
|
|
772
|
+
*
|
|
773
|
+
* Query a SCEP CA's advertised capabilities (RFC 8894 sec. 3.5.1): a GET `?operation=GetCACaps` whose
|
|
774
|
+
* `text/plain` body is parsed by `parseCapabilities`. The GetCACaps response is UNAUTHENTICATED (sec.
|
|
775
|
+
* 7.5), so a client MUST NOT downgrade its algorithm choice based on it; this verb only reports what the
|
|
776
|
+
* CA claims. Pass `expectSCEPStandard: true` (or `requireStrongProfile: true`) to fail closed when the
|
|
777
|
+
* strong profile is absent, treating the absence as the downgrade signal it is.
|
|
778
|
+
*
|
|
779
|
+
* @opts
|
|
780
|
+
* - `transport` -- an injected transport (defaults to `pki.transport.https`); the request is a GET with no body.
|
|
781
|
+
* - `expectSCEPStandard` -- refuse (`scep/weak-profile`) if the response does not advertise `SCEPStandard`.
|
|
782
|
+
* - `requireStrongProfile` -- refuse unless AES + SHA-256 + POSTPKIOperation are all advertised.
|
|
783
|
+
* - `timeout` / `maxResponseBytes` / `maxRedirects` / `tls` / `allowCrossOriginRedirect` -- transport bounds.
|
|
784
|
+
* @example
|
|
785
|
+
* // requires: scepTransport -- an injected transport (omit to use pki.transport.https)
|
|
786
|
+
* var caps = await pki.scep.getCACaps("http://ca.example/scep", { transport: scepTransport });
|
|
787
|
+
* caps.AES === true;
|
|
788
|
+
*/
|
|
789
|
+
async function getCACaps(baseUrl, opts) {
|
|
790
|
+
opts = guard.identifier.optionsObject(opts, _err, "scep/bad-input", "pki.scep.getCACaps options");
|
|
791
|
+
guard.identifier.assertKnownKeys(opts, _CACAPS_KEYS, _err, "scep/bad-input", "pki.scep.getCACaps has an unknown option: ");
|
|
792
|
+
// Capture the strong-profile policy at the door: the downgrade check runs only after the GET await, so a
|
|
793
|
+
// caller must not be able to relax its own guard by mutating opts while the request is in flight.
|
|
794
|
+
var profile = { expectSCEPStandard: !!opts.expectSCEPStandard, requireStrongProfile: !!opts.requireStrongProfile };
|
|
795
|
+
var res = await _client("GET", _scepUrl(baseUrl, "GetCACaps"), null, {}, opts);
|
|
796
|
+
// An HTTP error or empty body means "none supported" (RFC 8894 sec. 3.5.1); a non-200 that is not a
|
|
797
|
+
// clean error is still surfaced so a caller is not silently told "no capabilities" on a broken CA.
|
|
798
|
+
if (res.status === 200) {
|
|
799
|
+
// A conforming GetCACaps response carries content-type text/plain (RFC 8894 sec. 3.5.1). Enforcing it
|
|
800
|
+
// stops a proxy's 200 HTML error page from being read as an empty (no capabilities) capability set.
|
|
801
|
+
var caps = parseCapabilities(_requireOk(res, [CT_PLAIN], "GetCACaps"));
|
|
802
|
+
_assertStrongProfile(caps, profile);
|
|
803
|
+
return caps;
|
|
804
|
+
}
|
|
805
|
+
// A CA that does not implement GetCACaps signals it with an HTTP error, which RFC 8894 sec. 3.5.1 reads
|
|
806
|
+
// as "no capabilities advertised": the codes that mean the operation is unsupported or unrecognized are
|
|
807
|
+
// 400 (an old CA that does not parse the query), 404 (no such operation), 405 (GET not allowed on it), and
|
|
808
|
+
// 501 Not Implemented. A transient server error (500, 502, 503) is surfaced instead of masked.
|
|
809
|
+
if (res.status === 204 || res.status === 400 || res.status === 404 || res.status === 405 || res.status === 501) { _assertStrongProfile({}, profile); return {}; }
|
|
810
|
+
throw _err("scep/http-error", "the SCEP GetCACaps returned HTTP " + res.status);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// The exact SPKI byte-match that identifies an issued certificate (RFC 5272 sec. 4.1); positional
|
|
814
|
+
// guessing is forbidden. `certs` is an array of raw cert Buffers, `wantSpki` the SPKI Buffer to match.
|
|
815
|
+
function findIssuedCert(certs, wantSpki) {
|
|
816
|
+
if (!Buffer.isBuffer(wantSpki)) return null;
|
|
817
|
+
for (var i = 0; i < certs.length; i++) {
|
|
818
|
+
var spki;
|
|
819
|
+
try { spki = schemaX509.parse(certs[i]).subjectPublicKeyInfo; }
|
|
820
|
+
catch (_e) { continue; }
|
|
821
|
+
if (spki && Buffer.isBuffer(spki.bytes) && spki.bytes.equals(wantSpki)) return certs[i];
|
|
822
|
+
}
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
// Normalize an expected fingerprint (hex string, optionally colon/space separated, or a Buffer) to
|
|
826
|
+
// { algorithm, value }. Character-scan the hex (Hard rule #11).
|
|
827
|
+
function _normalizeFingerprint(fp, algName) {
|
|
828
|
+
var algorithm = _foldLower(String(algName == null ? "sha256" : algName));
|
|
829
|
+
// Validate the digest name at the entry point so a misspelled or build-unavailable fingerprintAlgorithm
|
|
830
|
+
// is the typed scep/bad-input the client API documents, not a native createHash exception downstream.
|
|
831
|
+
try { nodeCrypto.createHash(algorithm); }
|
|
832
|
+
catch (_e) { throw _err("scep/bad-input", "fingerprintAlgorithm " + JSON.stringify(algorithm) + " is not an available hash"); }
|
|
833
|
+
var value;
|
|
834
|
+
if (Buffer.isBuffer(fp)) value = Buffer.from(fp);
|
|
835
|
+
else if (typeof fp === "string") {
|
|
836
|
+
var hex = "", i, n = fp.length;
|
|
837
|
+
for (i = 0; i < n; i++) {
|
|
838
|
+
var c = fp.charCodeAt(i);
|
|
839
|
+
if ((c >= 48 && c <= 57) || (c >= 97 && c <= 102) || (c >= 65 && c <= 70)) hex += fp.charAt(i);
|
|
840
|
+
else if (c === 0x3a || c === 0x20) continue;
|
|
841
|
+
else throw _err("scep/bad-input", "expectedFingerprint must be a hex string or a Buffer");
|
|
842
|
+
}
|
|
843
|
+
if (hex.length === 0 || hex.length % 2 !== 0) throw _err("scep/bad-input", "expectedFingerprint hex has an odd or zero length");
|
|
844
|
+
value = Buffer.from(hex, "hex");
|
|
845
|
+
} else throw _err("scep/bad-input", "expectedFingerprint must be a hex string or a Buffer");
|
|
846
|
+
return { algorithm: algorithm, value: value };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* @primitive pki.scep.getCACert
|
|
851
|
+
* @signature pki.scep.getCACert(baseUrl, opts?) -> Promise<{ caCertificate, certificates }>
|
|
852
|
+
* @since 0.6.7
|
|
853
|
+
* @status stable
|
|
854
|
+
* @spec RFC 8894
|
|
855
|
+
* @related pki.scep.enroll, pki.path.validate
|
|
856
|
+
*
|
|
857
|
+
* Retrieve a SCEP CA's certificate (RFC 8894 sec. 4.2): a GET `?operation=GetCACert` returning either a
|
|
858
|
+
* single DER certificate (`application/x-x509-ca-cert`) or a certs-only CMS chain
|
|
859
|
+
* (`application/x-x509-ca-ra-cert`). GetCACert is served in the clear; the ONLY authentication is an
|
|
860
|
+
* out-of-band fingerprint (RFC 8894 sec. 2.2). Pass `expectedFingerprint` (a hex string or Buffer, with
|
|
861
|
+
* `fingerprintAlgorithm` defaulting to SHA-256) and this refuses (`scep/fingerprint-mismatch`) unless a
|
|
862
|
+
* returned certificate matches it, returning that certificate as `caCertificate`. A single-certificate
|
|
863
|
+
* response is unambiguously the CA, so `caCertificate` holds it. A ca-ra chain lists the CA, any
|
|
864
|
+
* intermediates, and RA certificate(s) in a CMS order that does not identify the CA role, so without a
|
|
865
|
+
* fingerprint `caCertificate` is null and the caller reads `certificates` to identify and authenticate
|
|
866
|
+
* the CA itself.
|
|
867
|
+
*
|
|
868
|
+
* @opts
|
|
869
|
+
* - `expectedFingerprint` -- a hex string or Buffer; a returned certificate must hash to it, or the response is refused.
|
|
870
|
+
* - `fingerprintAlgorithm` -- the hash for the fingerprint (default "sha256").
|
|
871
|
+
* - `transport` / `timeout` / `maxResponseBytes` / `maxRedirects` / `tls` / `allowCrossOriginRedirect` -- transport bounds.
|
|
872
|
+
* @example
|
|
873
|
+
* // requires: scepCaCertTransport -- an injected transport returning a single CA certificate
|
|
874
|
+
* var ca = await pki.scep.getCACert("http://ca.example/scep", { transport: scepCaCertTransport });
|
|
875
|
+
* ca.caCertificate; // the CA certificate DER (a single-certificate response identifies it)
|
|
876
|
+
*/
|
|
877
|
+
async function getCACert(baseUrl, opts) {
|
|
878
|
+
opts = guard.identifier.optionsObject(opts, _err, "scep/bad-input", "pki.scep.getCACert options");
|
|
879
|
+
guard.identifier.assertKnownKeys(opts, _CACERT_KEYS, _err, "scep/bad-input", "pki.scep.getCACert has an unknown option: ");
|
|
880
|
+
// Normalize the expected fingerprint at the synchronous entry point: fail fast on a bad hash before the
|
|
881
|
+
// network call, and pin against the value the caller supplied at the call rather than one mutated while
|
|
882
|
+
// the GET is in flight.
|
|
883
|
+
var fp = opts.expectedFingerprint != null ? _normalizeFingerprint(opts.expectedFingerprint, opts.fingerprintAlgorithm) : null;
|
|
884
|
+
var res = await _client("GET", _scepUrl(baseUrl, "GetCACert"), null, {}, opts);
|
|
885
|
+
var body = _requireOk(res, [CT_CA_CERT, CT_CA_RA_CERT], "GetCACert");
|
|
886
|
+
var ct = _mediaType(_header(res.headers, "content-type")), certs, single = false;
|
|
887
|
+
if (ct === CT_CA_CERT) {
|
|
888
|
+
try { schemaX509.parse(body); } catch (e) { throw _err("scep/bad-response", "the GetCACert response is not a valid X.509 certificate", e); }
|
|
889
|
+
certs = [Buffer.from(body)];
|
|
890
|
+
single = true;
|
|
891
|
+
} else {
|
|
892
|
+
// parseCertsOnly fails closed with scep/no-certificates on an empty CertificateSet, so certs is non-empty here.
|
|
893
|
+
certs = schemaCms.parseCertsOnly(body, _err, "scep").certificates;
|
|
894
|
+
}
|
|
895
|
+
var caCertificate = null;
|
|
896
|
+
if (fp != null) {
|
|
897
|
+
var matched = null, j;
|
|
898
|
+
for (j = 0; j < certs.length; j++) {
|
|
899
|
+
var got = nodeCrypto.createHash(fp.algorithm).update(certs[j]).digest();
|
|
900
|
+
if (got.length === fp.value.length && got.equals(fp.value)) { matched = certs[j]; break; }
|
|
901
|
+
}
|
|
902
|
+
if (!matched) throw _err("scep/fingerprint-mismatch", "no GetCACert certificate matched the expected " + fp.algorithm + " fingerprint (RFC 8894 sec. 2.2)");
|
|
903
|
+
caCertificate = matched;
|
|
904
|
+
} else if (single) {
|
|
905
|
+
// A single-certificate response (application/x-x509-ca-cert) is unambiguously the CA certificate. A
|
|
906
|
+
// ca-ra chain carries the CA, any intermediates, and RA certificate(s) in a CMS CertificateSet whose
|
|
907
|
+
// DER order does not identify the CA role, so caCertificate stays null there unless a fingerprint pins it.
|
|
908
|
+
caCertificate = certs[0];
|
|
909
|
+
}
|
|
910
|
+
return { caCertificate: caCertificate, certificates: certs };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// The default transaction ID: 16 random bytes, hex-encoded. RFC 8894 sec. 3.2.1.1 requires the ID to be
|
|
914
|
+
// unique per operation, so a per-key value would collide across separate enrollments of the same key (a
|
|
915
|
+
// renewal could not be told from the original request). A caller wanting a stable per-device ID passes an
|
|
916
|
+
// explicit transactionId.
|
|
917
|
+
function _defaultTransactionId() {
|
|
918
|
+
return nodeCrypto.randomBytes(16).toString("hex");
|
|
919
|
+
}
|
|
920
|
+
async function _pkiOperation(messageType, baseUrl, opts, verbName) {
|
|
921
|
+
opts = guard.identifier.optionsObject(opts, _err, "scep/bad-input", "pki.scep." + verbName + " options");
|
|
922
|
+
guard.identifier.assertKnownKeys(opts, _ENROLL_KEYS, _err, "scep/bad-input", "pki.scep." + verbName + " has an unknown option: ");
|
|
923
|
+
if (opts.csr == null) throw _err("scep/bad-input", "a csr (PKCS#10 DER) is required");
|
|
924
|
+
if (opts.caCert == null) throw _err("scep/bad-input", "a caCert (the SCEP CA certificate DER) is required");
|
|
925
|
+
if (opts.signer == null || typeof opts.signer !== "object") throw _err("scep/bad-input", "a signer { cert, key } is required");
|
|
926
|
+
if (opts.recipientKey == null || typeof opts.recipientKey !== "object") throw _err("scep/bad-input", "a recipientKey { cert, key } is required to decrypt the CA response");
|
|
927
|
+
// Snapshot the response-authentication inputs at the synchronous entry point: the response is
|
|
928
|
+
// authenticated (signerCert) and decrypted (recipientKey) only after the build and POST awaits, so a
|
|
929
|
+
// caller mutating opts mid-flight cannot switch the trusted signer, or the CA the request was encrypted
|
|
930
|
+
// to, out from under the exchange. Certificate bytes are copied; the recipient private key and the
|
|
931
|
+
// request signer are held by reference.
|
|
932
|
+
var caCert = guard.bytes.snapshotSource(opts.caCert, ScepError, "scep/bad-input", "the caCert");
|
|
933
|
+
var signerCert = opts.responderCert != null ? guard.bytes.snapshotSource(opts.responderCert, ScepError, "scep/bad-input", "the responderCert") : caCert;
|
|
934
|
+
// Capture the recipient-key FIELDS at the door (snapshotSource copies the cert bytes; the private key is
|
|
935
|
+
// held by reference), not the opts.recipientKey object: a caller replacing opts.recipientKey.cert or .key
|
|
936
|
+
// while the POST is in flight must not switch the credential the response is decrypted with.
|
|
937
|
+
var recipientKey = _captureRecipientKey(opts.recipientKey);
|
|
938
|
+
var signer = opts.signer;
|
|
939
|
+
// The transport options are read by _client only after the build await, so snapshot them at the door too:
|
|
940
|
+
// a caller replacing opts.transport (rerouting the completed request) or opts.tls.cert/.key (presenting the
|
|
941
|
+
// next operation's mTLS identity) while the POST is in flight must not take effect. tls is shallow-copied
|
|
942
|
+
// so a replaced field does not reach the transport; the transport function and budgets are held by value.
|
|
943
|
+
var clientOpts = {
|
|
944
|
+
transport: opts.transport,
|
|
945
|
+
tls: opts.tls != null ? Object.assign({}, opts.tls) : opts.tls,
|
|
946
|
+
maxRedirects: opts.maxRedirects,
|
|
947
|
+
allowCrossOriginRedirect: opts.allowCrossOriginRedirect,
|
|
948
|
+
timeout: opts.timeout,
|
|
949
|
+
maxResponseBytes: opts.maxResponseBytes,
|
|
950
|
+
};
|
|
951
|
+
// Resolve the request URL at the door: _scepUrl reads String(baseUrl) only when the POST is sent (after the
|
|
952
|
+
// build await), so a baseUrl whose toString() the caller changes mid-flight could otherwise redirect it.
|
|
953
|
+
var postUrl = _scepUrl(baseUrl, "PKIOperation");
|
|
954
|
+
var csrView = guard.bytes.snapshotSource(opts.csr, ScepError, "scep/bad-input", "the csr");
|
|
955
|
+
var spki;
|
|
956
|
+
try { spki = schemaCsr.parse(csrView).subjectPublicKeyInfo; }
|
|
957
|
+
catch (e) { throw _err("scep/bad-input", "the csr is not a valid PKCS#10 CertificationRequest", e); }
|
|
958
|
+
var senderNonce = nodeCrypto.randomBytes(16);
|
|
959
|
+
var transactionId = opts.transactionId != null ? opts.transactionId : _defaultTransactionId();
|
|
960
|
+
var reqMsg = await build({ messageType: messageType, messageData: csrView, recipient: caCert, signer: signer, transactionId: transactionId, senderNonce: senderNonce });
|
|
961
|
+
var res = await _client("POST", postUrl, reqMsg, { "content-type": CT_PKI_MESSAGE }, clientOpts);
|
|
962
|
+
var body = _requireOk(res, [CT_PKI_MESSAGE], "PKIOperation");
|
|
963
|
+
var rep = await parse(body, { signerCert: signerCert, expectedSenderNonce: senderNonce, recipientKey: recipientKey });
|
|
964
|
+
if (rep.messageType !== "CertRep") throw _err("scep/unexpected-message-type", "the SCEP response is a " + rep.messageType + ", not a CertRep");
|
|
965
|
+
// Every message in one SCEP transaction carries the same transactionID (RFC 8894 sec. 3.2.1.1); a
|
|
966
|
+
// response whose transactionID does not echo the request is for a different exchange and is refused,
|
|
967
|
+
// even if its nonce and signer check out.
|
|
968
|
+
if (rep.transactionId !== transactionId) throw _err("scep/transaction-id-mismatch", "the CertRep transactionID does not match the request (RFC 8894 sec. 3.2.1.1)");
|
|
969
|
+
if (rep.pkiStatus === "FAILURE") {
|
|
970
|
+
// Surface the CA's reason as structured fields, not only interpolated text, so a caller can branch on
|
|
971
|
+
// the failInfo enumerant (RFC 8894 sec. 3.3.2.1) without parsing the human-readable message.
|
|
972
|
+
var failErr = _err("scep/enrollment-failed", "the CA refused the request with failInfo " + JSON.stringify(rep.failInfo) + (rep.failInfoText ? " (" + rep.failInfoText + ")" : ""));
|
|
973
|
+
failErr.failInfo = rep.failInfo;
|
|
974
|
+
failErr.failInfoText = rep.failInfoText;
|
|
975
|
+
throw failErr;
|
|
976
|
+
}
|
|
977
|
+
if (rep.pkiStatus === "PENDING") return { status: "PENDING", transactionId: rep.transactionId, senderNonce: senderNonce };
|
|
978
|
+
var repCerts = rep.certificates || [];
|
|
979
|
+
var issued = findIssuedCert(repCerts, spki.bytes);
|
|
980
|
+
if (issued == null) throw _err("scep/no-issued-cert", "the SUCCESS CertRep did not carry a certificate matching the request public key");
|
|
981
|
+
// A CMS CertificateSet states no issuance order, so a second certificate carrying the request public key
|
|
982
|
+
// (a same-key renewal returning old and new, or a duplicate) leaves the issued certificate ambiguous; it
|
|
983
|
+
// is refused rather than resolved by position.
|
|
984
|
+
if (findIssuedCert(repCerts.filter(function (c) { return c !== issued; }), spki.bytes) != null) {
|
|
985
|
+
throw _err("scep/ambiguous-issued-cert", "the SUCCESS CertRep carried more than one certificate matching the request public key; the issued certificate is ambiguous (RFC 8894 sec. 3.3)");
|
|
986
|
+
}
|
|
987
|
+
return { status: "SUCCESS", certificate: issued, certificates: repCerts, transactionId: rep.transactionId };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* @primitive pki.scep.enroll
|
|
992
|
+
* @signature pki.scep.enroll(baseUrl, opts) -> Promise<{ status, certificate?, certificates?, transactionId }>
|
|
993
|
+
* @since 0.6.7
|
|
994
|
+
* @status stable
|
|
995
|
+
* @spec RFC 8894
|
|
996
|
+
* @related pki.scep.build, pki.scep.getCACert, pki.csr.sign
|
|
997
|
+
*
|
|
998
|
+
* Enrol for a certificate over SCEP (RFC 8894 sec. 4.3): build a PKCSReq wrapping the caller's PKCS#10 in
|
|
999
|
+
* a pkcsPKIEnvelope encrypted to the CA certificate, HTTP POST it to `?operation=PKIOperation`, and read
|
|
1000
|
+
* the CertRep. The CA response is authenticated against the CA certificate and its `recipientNonce` must
|
|
1001
|
+
* echo the request's fresh `senderNonce`. On SUCCESS the issued certificate is selected out of the
|
|
1002
|
+
* response by SubjectPublicKeyInfo byte-match against the request; a FAILURE throws
|
|
1003
|
+
* `scep/enrollment-failed` carrying the CA's failInfo; a PENDING returns `{ status: "PENDING",
|
|
1004
|
+
* transactionId }` for the caller to retry. The content key is transported under RSAES-OAEP, so the CA
|
|
1005
|
+
* certificate must be an RSA `keyEncipherment` certificate that accepts OAEP; the shared-secret
|
|
1006
|
+
* authenticator (RFC 8894 sec. 2.4) is the `challengePassword` the caller placed in the PKCS#10.
|
|
1007
|
+
*
|
|
1008
|
+
* @opts
|
|
1009
|
+
* - `csr` -- the PKCS#10 CertificationRequest DER to enrol (place a `challengePassword` in it for shared-secret auth).
|
|
1010
|
+
* - `caCert` -- the SCEP CA certificate DER the request is encrypted to.
|
|
1011
|
+
* - `signer` -- `{ cert, key }`, the outer SignedData signer (for a PKCSReq, a self-signed certificate over the request key).
|
|
1012
|
+
* - `recipientKey` -- `{ cert, key }`, the RSA key the CA encrypts the response to, to decrypt the CertRep.
|
|
1013
|
+
* - `responderCert` -- the certificate to authenticate the CA's response signature against (defaults to `caCert`).
|
|
1014
|
+
* - `transactionId` -- a caller-unique PrintableString (defaults to 16 random bytes, hex-encoded, unique per call).
|
|
1015
|
+
* - `transport` / `timeout` / `maxResponseBytes` / `maxRedirects` / `tls` / `allowCrossOriginRedirect` -- transport bounds.
|
|
1016
|
+
* @example
|
|
1017
|
+
* // requires: scepCsr, caCertDer, clientCertDer, clientKeyPkcs8, scepTransport
|
|
1018
|
+
* var out = await pki.scep.enroll("http://ca.example/scep", { csr: scepCsr, caCert: caCertDer,
|
|
1019
|
+
* signer: { cert: clientCertDer, key: clientKeyPkcs8 },
|
|
1020
|
+
* recipientKey: { cert: clientCertDer, key: clientKeyPkcs8 }, transport: scepTransport });
|
|
1021
|
+
* out.status; // "SUCCESS", or "PENDING" to retry
|
|
1022
|
+
*/
|
|
1023
|
+
function enroll(baseUrl, opts) { return _pkiOperation("PKCSReq", baseUrl, opts, "enroll"); }
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* @primitive pki.scep.renew
|
|
1027
|
+
* @signature pki.scep.renew(baseUrl, opts) -> Promise<{ status, certificate?, certificates?, transactionId }>
|
|
1028
|
+
* @since 0.6.7
|
|
1029
|
+
* @status stable
|
|
1030
|
+
* @spec RFC 8894
|
|
1031
|
+
* @related pki.scep.enroll
|
|
1032
|
+
*
|
|
1033
|
+
* Renew a certificate over SCEP (RFC 8894 sec. 4.3): identical to `enroll` but building a RenewalReq,
|
|
1034
|
+
* whose outer SignedData is signed with the caller's existing CA-issued certificate rather than a
|
|
1035
|
+
* self-signed one. Because the request is signed with the existing key and not the new PKCS#10 key, a
|
|
1036
|
+
* RenewalReq does not itself prove possession of the new key (RFC 8894 sec. 7.6); the proof-of-possession
|
|
1037
|
+
* over the PKCS#10 is still verified. Options are the same as `enroll`, with `signer` being the existing
|
|
1038
|
+
* certificate and key.
|
|
1039
|
+
*
|
|
1040
|
+
* @example
|
|
1041
|
+
* // requires: scepCsr, caCertDer, clientCertDer, clientKeyPkcs8, scepTransport
|
|
1042
|
+
* var out = await pki.scep.renew("http://ca.example/scep", { csr: scepCsr, caCert: caCertDer,
|
|
1043
|
+
* signer: { cert: clientCertDer, key: clientKeyPkcs8 },
|
|
1044
|
+
* recipientKey: { cert: clientCertDer, key: clientKeyPkcs8 }, transport: scepTransport });
|
|
1045
|
+
*/
|
|
1046
|
+
function renew(baseUrl, opts) { return _pkiOperation("RenewalReq", baseUrl, opts, "renew"); }
|
|
1047
|
+
|
|
1048
|
+
var _TRANSPORT_KEYS = { transport: 1, timeout: 1, maxResponseBytes: 1, maxRedirects: 1, tls: 1, allowCrossOriginRedirect: 1 };
|
|
1049
|
+
function _withKeys(extra) { var o = {}, k; for (k in _TRANSPORT_KEYS) o[k] = 1; for (k in extra) o[k] = 1; return o; }
|
|
1050
|
+
var _CACAPS_KEYS = _withKeys({ expectSCEPStandard: 1, requireStrongProfile: 1 });
|
|
1051
|
+
var _CACERT_KEYS = _withKeys({ expectedFingerprint: 1, fingerprintAlgorithm: 1 });
|
|
1052
|
+
var _ENROLL_KEYS = _withKeys({ csr: 1, caCert: 1, signer: 1, recipientKey: 1, responderCert: 1, transactionId: 1 });
|
|
1053
|
+
|
|
556
1054
|
module.exports = {
|
|
557
1055
|
build: build,
|
|
558
1056
|
parse: parse,
|
|
1057
|
+
getCACaps: getCACaps,
|
|
1058
|
+
getCACert: getCACert,
|
|
1059
|
+
enroll: enroll,
|
|
1060
|
+
renew: renew,
|
|
1061
|
+
parseCapabilities: parseCapabilities,
|
|
559
1062
|
MESSAGE_TYPES: MESSAGE_TYPE,
|
|
560
1063
|
PKI_STATUS: PKI_STATUS,
|
|
561
1064
|
FAIL_INFO: FAIL_INFO,
|
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:2201f01a-1b89-4433-a2aa-df6cc354e171",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-31T11:43:15.117Z",
|
|
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.6.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.6.7",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.6.
|
|
25
|
+
"version": "0.6.7",
|
|
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.6.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.6.7",
|
|
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.6.
|
|
57
|
+
"ref": "@blamejs/pki@0.6.7",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|