@blamejs/pki 0.3.27 → 0.3.28
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 +10 -0
- package/README.md +4 -4
- package/lib/constants.js +4 -0
- package/lib/est.js +352 -29
- package/lib/http-digest.js +379 -0
- package/lib/http-transport.js +5 -3
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ 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.3.28 — 2026-08-01
|
|
8
|
+
|
|
9
|
+
pki.est gains its remaining RFC 7030 network verbs -- request a server-generated key pair with pki.est.serverkeygen, fetch the CA's CSR-attributes policy with pki.est.csrattrs, and authenticate with HTTP Digest as an alternative to HTTP Basic.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.est.serverkeygen(baseUrl, csr, opts?) requests a server-generated key pair + certificate (RFC 7030 sec. 4.4): it POSTs the CSR (application/pkcs10, identical encoding to simpleenroll) to /.well-known/est/serverkeygen and returns { certificates, privateKey } for a cleartext PKCS#8 PrivateKeyInfo key part, { certificates, encryptedKey } for a CMS EnvelopedData key part (surfaced structurally, never decrypted), or { retry, retryAfterSeconds, retryAfterDate } on a 202. The certificates are returned raw (no leaf is selected -- the CA generated the key, so the issued certificate carries the generated public key, not the throwaway CSR key). A cleartext key is bound to its certificate before it is surfaced: the delivered private key's public half must match exactly one returned certificate, so a key mis-associated with the returned certificate set is refused rather than handed back unusable. Whether the key part must be encrypted, and to which recipient, is derived from the CSR's DecryptKeyIdentifier (a symmetric key-encryption key) or AsymmetricDecryptKeyIdentifier (an asymmetric key) attribute; the recipient MECHANISM is preserved and required to match the RecipientInfo arm, so a symmetric KEK recipient never satisfies an advertised asymmetric key (or vice versa) merely because the identifier bytes coincide. An opts value that contradicts the CSR is refused, and a cleartext key delivered where the CSR requested encryption is refused, as is an encrypted key delivered where the CSR advertised no decryption key to open it (an unusable, unsolicited credential). The delivered key's channel must negotiate a confidentiality-bearing cipher (a NULL / anonymous / EXPORT suite is refused). https-only, explicit-anchor, and the full redirect / auth / budget machinery of the enrollment verbs apply.
|
|
14
|
+
- pki.est.csrattrs(baseUrl, opts?) fetches the CA's CSR-attributes policy (RFC 7030 sec. 4.5, RFC 9908): a 200 application/csrattrs body is parsed and returned as { available: true, attrs, plan }, where plan is the enroll-attribute plan the caller builds its next CSR from; a 204 or 404 is { available: false } (a valid "CSR Attributes Response not available"); an empty CsrAttrs is a complete empty policy. The verb never applies attributes to a CSR itself. Server authentication is not required for this policy GET, but a 401 is honored so the auth path stays available.
|
|
15
|
+
- HTTP Digest access authentication (RFC 7616) is available on every EST verb as an alternative to HTTP Basic via opts.auth = { scheme: "digest", username, password }. SHA-256 and SHA-512-256 are supported; MD5 and MD5-sess are refused unless opts.auth.allowMD5 is set, and a legacy no-qop (RFC 2069) challenge is refused unless opts.auth.allowLegacyQop is set. Among multiple offered challenges the most secure USABLE one is chosen -- a challenge this client cannot answer under its policy (an unsupported or disallowed algorithm, or a qop mode it does not accept) never shadows a lower-ranked usable one, so the client authenticates on a usable offer instead of picking the strongest and failing; when no offer is usable it fails closed with the specific reason rather than downgrading to a weaker scheme. A server stale=true re-challenge is answered under a bounded opts.auth.maxStaleRetries budget. The response is scoped to the challenge's protection space: within an origin it is preemptively reused only for the URIs the challenge's domain directive covers (a quoted directive -- a malformed unquoted one is refused, not silently widened; an absolute-URI entry is matched by its authority as well as its path, so an entry naming a different host never widens the scope to this one; the query is kept in the comparison, so an entry scoped to one query does not cover a different one), and a same-origin resource outside that scope is authenticated afresh (bounded) -- classified by whether the request actually carried a credential, so a resource sent unauthenticated is never mistaken for a rejected one -- rather than sent the first realm's response or blocked outright. A non-ASCII username under a charset=UTF-8 challenge is carried in the RFC 5987 username* extended form (percent-encoded UTF-8) rather than the legacy quoted field a server would read as ISO-8859-1, and a non-ASCII realm's incoming header octets are hashed unchanged, so a UTF-8 realm contributes the same octets the server used and the response matches an RFC 7616 server. As with Basic, the credential is answered only on the origin the caller authenticated to -- never sent to a redirected origin, and never as a silent Basic downgrade of a Digest challenge.
|
|
16
|
+
|
|
7
17
|
## v0.3.27 — 2026-07-31
|
|
8
18
|
|
|
9
19
|
pki.cmp.session drives a full CMP certificate enrollment end to end -- build, transfer, and verify every leg of an ir/cr/kur/p10cr exchange, with every response protection-checked before its body is read.
|
package/README.md
CHANGED
|
@@ -216,7 +216,7 @@ is callable today; nothing below is a stub.
|
|
|
216
216
|
| `pki.schema.pkcs12` | Parse DER / BER / PEM RFC 7292 PKCS#12 (PFX) stores — key bags via the PKCS#8 parser, shrouded keys (algorithm surfaced, ciphertext opaque), cert / CRL / secret bags raw and byte-exact, encrypted and enveloped safes structurally via CMS, `friendlyName` / `localKeyId` decoded, and the exact MAC byte range (`macedBytes`) plus RFC 9579 PBMAC1 recognition for external verification; BER accepted exactly where §4.1 requires it, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
217
217
|
| `pki.schema.cmp` | Parse DER / PEM RFC 9810 Certificate Management Protocol messages (PKIMessage) — the header (version, sender / recipient incl. 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` / `bodyBytes` slices an external verifier reconstructs the protected part from; the CMP-before-OCSP dispatch order is enforced, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
218
218
|
| `pki.schema.csrattrs` | Parse DER 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/RSA key-type conventions, the certification-request-info template). Unknown types tolerated (surfaced raw), fail-closed on structure and the RFC 9908 semantic MUSTs — `parse` |
|
|
219
|
-
| `pki.est` | RFC 7030 / 8951 / 9908 Enrollment over Secure Transport — the thin client verbs `cacerts` / `simpleenroll` / `simplereenroll` drive the RFC 7030 flow over `pki.transport` (inject your own, or the fail-closed default): https-only, an explicit trust anchor required, same-origin redirects followed but a downgrade / loop refused, a 202 Retry-After surfaced (never slept), HTTP Basic answered only after the server is authenticated, and the issued certificate chosen by public-key match. Under them, the transport-agnostic codecs they compose — the RFC 8951 base64 transfer codec (blind to Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only + serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `cacerts`, `simpleenroll`, `simplereenroll`, `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
|
|
219
|
+
| `pki.est` | RFC 7030 / 8951 / 9908 / 7616 Enrollment over Secure Transport — the thin client verbs `cacerts` / `simpleenroll` / `simplereenroll` / `serverkeygen` / `csrattrs` drive the RFC 7030 flow over `pki.transport` (inject your own, or the fail-closed default): https-only, an explicit trust anchor required, same-origin redirects followed but a downgrade / loop refused, a 202 Retry-After surfaced (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, encryption bound to the CSR's key-identifier attribute, over a confidentiality-bearing cipher); `csrattrs` fetches the CA's RFC 9908 attributes policy. Under them, the transport-agnostic codecs they compose — the RFC 8951 base64 transfer codec (blind to Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only + serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `cacerts`, `simpleenroll`, `simplereenroll`, `serverkeygen`, `csrattrs`, `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
|
|
220
220
|
| `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive — `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body }`. The toolkit's sole 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. Reused verbatim by the EST, ACME, and CMP clients — `https` |
|
|
221
221
|
| `pki.jose` | RFC 7515 Flattened JWS + RFC 7638 JWK thumbprints — `sign` / `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required/forbidden header rules as data; `base64url` is the strict RFC 4648 §5 codec (padding, non-alphabet, and non-canonical trailing bits rejected); `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), so `alg:none`, an RS256→HS256 key confusion, and an all-zero ECDSA signature have no code path; `assertPublicJwk` refuses a JWK carrying private material so an exported private key is never published — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
|
|
222
222
|
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME — `client(directoryUrl, opts)` is a stateful client that drives a live CA directory over `pki.transport` (inject your own, or the fail-closed default): `newAccount` / `newOrder` / `getOrder` / `getAuthorization` / `getChallenge` / `respondToChallenge` / `finalize` / `pollOrder` / `pollAuthorization` / `downloadCertificate` walk the issuance flow, and `revokeCert` (account-key or certificate-key signed), `keyChange` (account key rotation), `deactivateAccount` / `deactivateAuthorization`, and `renewalInfo` (ARI) round out the lifecycle — https-only for every URL, an explicit trust anchor required, a fresh single-use nonce per request with a bounded badNonce retry, POST-as-GET reads, bounded polling that sleeps on a Retry-After via an injectable sleeper (capped by a poll count and a total-wait budget), and every response body size-capped. 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, request builders (newAccount + EAB, newOrder + `replaces`, finalize with 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/ip identifier validators, and the ARI certID (serial sign-padding preserved), fail-closed — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
@@ -272,9 +272,9 @@ certificate and writes a DER (or `--pem`) SignedData to `--out` or stdout.
|
|
|
272
272
|
|
|
273
273
|
### What's coming
|
|
274
274
|
|
|
275
|
-
The remaining EST network
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
The remaining EST network verb (`/fullcmc`), SCEP and CMC enrollment, and
|
|
276
|
+
additional NIST-on-ramp PQC signatures as the OID registry admits them are on
|
|
277
|
+
the roadmap and ride this same
|
|
278
278
|
core. See
|
|
279
279
|
[ROADMAP.md](ROADMAP.md) for the full plan and current status of each area, and
|
|
280
280
|
[CHANGELOG.md](CHANGELOG.md) for what has landed.
|
package/lib/constants.js
CHANGED
|
@@ -161,6 +161,10 @@ var LIMITS = {
|
|
|
161
161
|
// document plus multipart/header overhead (16 MiB * 4/3 ~= 21.3 MiB); 24 MiB
|
|
162
162
|
// clears it. An operator may only tighten it DOWNWARD via opts.maxResponseBytes.
|
|
163
163
|
HTTP_MAX_RESPONSE_BYTES: BYTES.mib(24),
|
|
164
|
+
// A single untrusted WWW-Authenticate challenge header, bounded BEFORE the string
|
|
165
|
+
// is copied/tokenized so a hostile server cannot drive unbounded work parsing it
|
|
166
|
+
// (CWE-770). 8 KiB clears any realistic multi-scheme Digest challenge.
|
|
167
|
+
HTTP_AUTH_HEADER_MAX_BYTES: BYTES.kib(8),
|
|
164
168
|
// Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbours' siblings:
|
|
165
169
|
// a whole-document cap refused before the walk, a nesting cap, and a per-value
|
|
166
170
|
// bignum ceiling the document cap can't provide. Unlike DER_MAX_INTEGER_BYTES,
|
package/lib/est.js
CHANGED
|
@@ -57,10 +57,13 @@ var constants = require("./constants");
|
|
|
57
57
|
var cms = require("./schema-cms");
|
|
58
58
|
var x509 = require("./schema-x509");
|
|
59
59
|
var pkcs8 = require("./schema-pkcs8");
|
|
60
|
+
var key = require("./key");
|
|
60
61
|
var csr = require("./schema-csr");
|
|
62
|
+
var csrattrsFmt = require("./schema-csrattrs"); // aliased: the `csrattrs` name is the verb + the public export
|
|
61
63
|
var frameworkError = require("./framework-error");
|
|
62
64
|
var guard = require("./guard-all");
|
|
63
65
|
var httpTransport = require("./http-transport");
|
|
66
|
+
var httpDigest = require("./http-digest");
|
|
64
67
|
var retryAfter = require("./http-retry-after");
|
|
65
68
|
|
|
66
69
|
var EstError = frameworkError.EstError;
|
|
@@ -175,7 +178,10 @@ function splitMultipartMixed(body, contentType) {
|
|
|
175
178
|
var rawHeaders = seg.slice(0, sep);
|
|
176
179
|
var partBody = seg.slice(sep).replace(/^(\r?\n){2}/, "").replace(/\r?\n$/, "");
|
|
177
180
|
var headers = {};
|
|
178
|
-
|
|
181
|
+
// Unfold MIME part headers (RFC 5322 sec. 2.2.3 / RFC 2046): a CRLF immediately followed by whitespace is a
|
|
182
|
+
// FOLD, not a header break, so it is removed before the split -- a Content-Type whose smime-type parameter
|
|
183
|
+
// continues on the next line must be read whole, not truncated.
|
|
184
|
+
rawHeaders.replace(/\r?\n(?=[ \t])/g, "").split(/\r?\n/).forEach(function (line) {
|
|
179
185
|
var col = line.indexOf(":");
|
|
180
186
|
if (col > 0) headers[line.slice(0, col).trim().toLowerCase()] = line.slice(col + 1).trim();
|
|
181
187
|
});
|
|
@@ -235,14 +241,24 @@ function findIssuedCert(certs, target) {
|
|
|
235
241
|
// Collect every key-identifier (subjectKeyIdentifier / KEKIdentifier) a set of
|
|
236
242
|
// RecipientInfos names, across the ktri / kari / kekri / kemri arms -- the
|
|
237
243
|
// byte identifiers a client's advertised decryptKeyID would match.
|
|
238
|
-
|
|
244
|
+
// The key-identifier bytes each RecipientInfo arm names, filtered by MECHANISM (RFC 7030 sec. 4.4.1): an
|
|
245
|
+
// ASYMMETRIC decryption key (AsymmetricDecryptKeyIdentifier) is named by a subjectKeyIdentifier on a
|
|
246
|
+
// KeyTrans / KeyAgree / KEM arm, a SYMMETRIC key (DecryptKeyIdentifier) by the keyIdentifier of a KEK arm.
|
|
247
|
+
// Filtering keeps a symmetric KEK arm from satisfying an advertised asymmetric key (and vice-versa) merely
|
|
248
|
+
// because the identifier bytes coincide -- the returned key would be unusable with the mechanism requested.
|
|
249
|
+
// kind: "asymmetric" | "symmetric" | undefined (any, for a direct caller that did not state the mechanism).
|
|
250
|
+
function _recipientKeyIds(recipientInfos, kind) {
|
|
239
251
|
var ids = [];
|
|
240
252
|
function push(v) { if (Buffer.isBuffer(v)) ids.push(v); }
|
|
241
253
|
(recipientInfos || []).forEach(function (r) {
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
254
|
+
if (kind !== "symmetric") {
|
|
255
|
+
if (r.rid) push(r.rid.subjectKeyIdentifier);
|
|
256
|
+
if (r.kemri && r.kemri.rid) push(r.kemri.rid.subjectKeyIdentifier); // KEMRecipientInfo (RFC 9629, under the ori arm)
|
|
257
|
+
(r.recipientEncryptedKeys || []).forEach(function (rek) { if (rek.rid) push(rek.rid.subjectKeyIdentifier); });
|
|
258
|
+
}
|
|
259
|
+
if (kind !== "asymmetric") {
|
|
260
|
+
if (r.kekid) push(r.kekid.keyIdentifier);
|
|
261
|
+
}
|
|
246
262
|
});
|
|
247
263
|
return ids;
|
|
248
264
|
}
|
|
@@ -266,11 +282,23 @@ function _recipientIssuerSerials(recipientInfos) {
|
|
|
266
282
|
// form the server may have used to name the same requested key.
|
|
267
283
|
function _recipientMatches(recipientInfos, opts) {
|
|
268
284
|
if (Buffer.isBuffer(opts.expectedRecipientKeyId) &&
|
|
269
|
-
_recipientKeyIds(recipientInfos).some(function (id) { return id.equals(opts.expectedRecipientKeyId); })) return true;
|
|
285
|
+
_recipientKeyIds(recipientInfos, opts.expectedRecipientKind).some(function (id) { return id.equals(opts.expectedRecipientKeyId); })) return true;
|
|
270
286
|
var ias = opts.expectedRecipientIssuerSerial;
|
|
271
287
|
if (ias && Buffer.isBuffer(ias.issuer) && ias.serialNumber != null) {
|
|
272
|
-
|
|
273
|
-
|
|
288
|
+
// Validate the caller-supplied serial BEFORE BigInt() so a bad value is a typed est/bad-input, never a
|
|
289
|
+
// raw SyntaxError / RangeError leaking from the codec (the single choke point for both the serverkeygen
|
|
290
|
+
// verb and the public parseServerKeygenResponse).
|
|
291
|
+
var sn = ias.serialNumber, want;
|
|
292
|
+
if (typeof sn === "bigint" && sn >= 0n) want = sn;
|
|
293
|
+
else if (typeof sn === "number" && Number.isSafeInteger(sn) && sn >= 0) want = BigInt(sn);
|
|
294
|
+
else if (typeof sn === "string" && /^[0-9]+$/.test(sn)) want = BigInt(sn);
|
|
295
|
+
else throw E("est/bad-input", "expectedRecipientIssuerSerial.serialNumber must be a NON-NEGATIVE bigint, a safe non-negative integer, or a decimal digit string (a certificate serial is non-negative, RFC 5280 sec. 4.1.2.2)");
|
|
296
|
+
// An issuerAndSerialNumber names an ASYMMETRIC recipient -- a certificate identity the server derived from
|
|
297
|
+
// an AsymmetricDecryptKeyIdentifier (RFC 7030 sec. 4.4.2); a symmetric KEK recipient has none. So a SYMMETRIC
|
|
298
|
+
// request must NOT be satisfied by an asymmetric arm's issuer+serial, or the client would accept ciphertext
|
|
299
|
+
// undecryptable with the symmetric key it requested. The key-id branch applies the same mechanism filter.
|
|
300
|
+
if (opts.expectedRecipientKind !== "symmetric" &&
|
|
301
|
+
_recipientIssuerSerials(recipientInfos).some(function (r) { return r.issuer.equals(ias.issuer) && r.serialNumber === want; })) return true;
|
|
274
302
|
}
|
|
275
303
|
return false;
|
|
276
304
|
}
|
|
@@ -337,6 +365,11 @@ function parseServerKeygenResponse(body, contentType, opts) {
|
|
|
337
365
|
}
|
|
338
366
|
if (!keyPart || !certPart) throw E("est/bad-multipart", "a serverkeygen response needs one key part and one certificate part");
|
|
339
367
|
if (opts.requestedEncryption && !encrypted) throw E("est/expected-encrypted-key", "encryption was requested but the private-key part is cleartext (RFC 7030 sec. 4.4.2)");
|
|
368
|
+
// The reverse is also a mismatch: the CSR advertised NO key-encryption key (requestedEncryption explicitly
|
|
369
|
+
// false), yet the server returned an EnvelopedData. The client holds nothing to decrypt it, so an unsolicited
|
|
370
|
+
// encrypted key is an unusable credential, not a success (RFC 7030 sec. 4.4.2). (An undefined requestedEncryption
|
|
371
|
+
// -- a low-level caller not stating the mode -- stays permissive.)
|
|
372
|
+
if (opts.requestedEncryption === false && encrypted) throw E("est/unexpected-encrypted-key", "the server returned an encrypted key but the CSR advertised no DecryptKeyIdentifier / AsymmetricDecryptKeyIdentifier to decrypt it (RFC 7030 sec. 4.4.2)");
|
|
340
373
|
var out = { certificates: parseCertsOnly(transferDecode(certPart.body)).certificates };
|
|
341
374
|
if (encrypted) {
|
|
342
375
|
// The server-generated key MUST be a CMS EnvelopedData (RFC 7030 sec. 4.4.2);
|
|
@@ -360,7 +393,13 @@ function parseServerKeygenResponse(body, contentType, opts) {
|
|
|
360
393
|
}
|
|
361
394
|
out.encryptedKey = parsedKey;
|
|
362
395
|
} else {
|
|
363
|
-
|
|
396
|
+
var keyDer = transferDecode(keyPart.body);
|
|
397
|
+
out.privateKey = pkcs8.parse(keyDer);
|
|
398
|
+
// The raw cleartext PrivateKeyInfo DER is surfaced so a semantic layer can bind the key to its
|
|
399
|
+
// certificate (the public-key coherence check the verb runs). Structural parsing itself never
|
|
400
|
+
// does the crypto derivation -- mirroring parseCertsOnly, which splits certs while the enroll
|
|
401
|
+
// verb picks the issued one by public-key match.
|
|
402
|
+
out.privateKeyDer = keyDer;
|
|
364
403
|
}
|
|
365
404
|
return out;
|
|
366
405
|
}
|
|
@@ -677,15 +716,35 @@ function _redirectTarget(current, location, method, opts) {
|
|
|
677
716
|
return resolved;
|
|
678
717
|
}
|
|
679
718
|
|
|
680
|
-
// Does WWW-Authenticate advertise a
|
|
681
|
-
// (a quoted auth-param may contain a comma +
|
|
682
|
-
// must NOT be read as a Basic challenge), then
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
function
|
|
719
|
+
// Does WWW-Authenticate advertise the given auth-scheme as a TOKEN? The quoted-string contents are blanked
|
|
720
|
+
// first (a quoted auth-param may contain a comma + the scheme name, e.g. `Digest realm="x, Basic required"`,
|
|
721
|
+
// which must NOT be read as a Basic challenge), then the scheme is matched at the start or after a
|
|
722
|
+
// comma-separated challenge, followed by whitespace / a comma / end -- never a substring, so `Digest
|
|
723
|
+
// realm="basic"` is not taken as Basic. `scheme` is always our own literal ("Basic" / "Digest").
|
|
724
|
+
function _offersScheme(www, scheme) {
|
|
686
725
|
var s = String(www || "").replace(/"(?:[^"\\]|\\.)*"/g, "\"\"");
|
|
687
|
-
return
|
|
726
|
+
return new RegExp("(?:^|,)\\s*" + scheme + "(?=[\\s,]|$)", "i").test(s);
|
|
727
|
+
}
|
|
728
|
+
// The chosen HTTP auth scheme: an explicit opts.auth.scheme wins; a legacy opts.username / opts.password
|
|
729
|
+
// WITHOUT opts.auth means Basic (backwards compatible); otherwise none. There is NO scheme:"auto" -- a
|
|
730
|
+
// Basic<->Digest downgrade is never silently chosen (a MITM stripping Digest to Basic must not succeed).
|
|
731
|
+
function _authScheme(opts) {
|
|
732
|
+
if (opts.auth && opts.auth.scheme != null) {
|
|
733
|
+
var sc = String(opts.auth.scheme).toLowerCase();
|
|
734
|
+
if (sc !== "basic" && sc !== "digest") throw E("est/bad-input", "opts.auth.scheme must be \"basic\" or \"digest\"");
|
|
735
|
+
return sc;
|
|
736
|
+
}
|
|
737
|
+
if (opts.username !== undefined || opts.password !== undefined) return "basic";
|
|
738
|
+
return null;
|
|
688
739
|
}
|
|
740
|
+
function _authUser(opts) { return opts.auth && opts.auth.username !== undefined ? opts.auth.username : opts.username; }
|
|
741
|
+
function _authPass(opts) { return opts.auth && opts.auth.password !== undefined ? opts.auth.password : opts.password; }
|
|
742
|
+
// Credentials present -- an EMPTY username is allowed (RFC 7030 sec. 3.2.3), so "defined" (not "truthy").
|
|
743
|
+
function _hasCreds(opts) { return _authUser(opts) !== undefined || _authPass(opts) !== undefined; }
|
|
744
|
+
var DIGEST_CODES = { unsupportedAlgorithm: "est/digest-unsupported-algorithm", weakAlgorithm: "est/digest-weak-algorithm", noQop: "est/digest-no-qop", badChallenge: "est/digest-bad-challenge" };
|
|
745
|
+
// The Digest answer policy, from opts.auth. The SAME object drives challenge SELECTION (parseChallenge) and
|
|
746
|
+
// the ANSWER (answer), so the client never selects a challenge under one policy then answers under another.
|
|
747
|
+
function _digestPolicy(opts) { return { allowMD5: !!(opts.auth && opts.auth.allowMD5), allowLegacyQop: !!(opts.auth && opts.auth.allowLegacyQop), codes: DIGEST_CODES }; }
|
|
689
748
|
|
|
690
749
|
// The redirect-follow + HTTP-auth-retry loop. Returns the terminal transport response
|
|
691
750
|
// ({status, headers, body}) -- a 2xx, a 202, or a non-401 4xx/5xx. A 3xx is followed
|
|
@@ -696,6 +755,9 @@ function _hasBasicChallenge(www) {
|
|
|
696
755
|
function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
697
756
|
var redirects = 0;
|
|
698
757
|
var authTried = false;
|
|
758
|
+
var staleRetries = 0; // bounded re-answers to a Digest stale=true re-challenge (a fresh nonce), never an open loop
|
|
759
|
+
var authSpaces = 0; // distinct Digest protection spaces (realms) entered beyond the first -- bounded so a server cannot loop with an endless stream of new realms
|
|
760
|
+
var lastDigestNonce = null; // the nonce just answered -- a stale=true retry MUST carry a DIFFERENT (fresh) nonce
|
|
699
761
|
var initialOrigin = url.origin; // the origin the caller intended to authenticate to
|
|
700
762
|
// The per-hop TLS: the origin-specific identity (mTLS cert/key + pinned SNI) is sent ONLY to the caller's
|
|
701
763
|
// configured origin; a cross-origin hop gets a narrowed copy with those stripped (checkServerIdentity kept).
|
|
@@ -709,14 +771,30 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
|
709
771
|
}
|
|
710
772
|
return t;
|
|
711
773
|
}
|
|
712
|
-
// The per-hop Authorization
|
|
713
|
-
//
|
|
714
|
-
//
|
|
774
|
+
// The per-hop Authorization, sent ONLY to the caller's origin (a cross-origin hop travels unauthenticated; a
|
|
775
|
+
// redirect back to the origin restores it -- mirrors _tlsFor). A Basic credential is target-INDEPENDENT, so it
|
|
776
|
+
// is cached whole in authValue. A Digest response is bound to the request method + target, so the CHALLENGE is
|
|
777
|
+
// cached and the answer is (re)computed for the CURRENT method / uri on every hop -- a same-origin redirect (or
|
|
778
|
+
// a 303 POST->GET) then gets a target-correct answer instead of a stale one the server would reject.
|
|
715
779
|
var authValue = null;
|
|
780
|
+
var digestChallenge = null;
|
|
781
|
+
var digestNcByNonce = Object.create(null); // nonce string -> count of requests answered under THAT nonce, tracked PER NONCE so a nonce reissued after others were used never repeats an nc (RFC 7616 sec. 3.4); bounded by the total answered 401s; a null-proto map so an attacker-chosen nonce (e.g. "__proto__") is an ordinary key
|
|
782
|
+
var digestSent = false; // whether the LAST request actually carried a Digest Authorization -- the DIRECT signal that a following 401 rejects a credential, vs. is a fresh challenge for a request sent unauthenticated
|
|
716
783
|
function _headersFor(u) {
|
|
717
|
-
|
|
784
|
+
digestSent = false;
|
|
785
|
+
if (u.origin !== initialOrigin || (!authValue && !digestChallenge)) return headers;
|
|
718
786
|
var hh = Object.assign({}, headers);
|
|
719
|
-
|
|
787
|
+
if (digestChallenge) {
|
|
788
|
+
// Preemptively reuse the cached answer only WITHIN the challenge's protection space (RFC 7616 sec. 3.5):
|
|
789
|
+
// a same-origin redirect to a URI outside the challenge's `domain` is sent unauthenticated, so the target
|
|
790
|
+
// resource's own challenge drives a fresh authentication instead of receiving an Authorization computed
|
|
791
|
+
// for the wrong protection space.
|
|
792
|
+
if (!httpDigest.inProtectionSpace(digestChallenge, u.origin, u.pathname + u.search)) return headers;
|
|
793
|
+
var nkey = digestChallenge.nonce;
|
|
794
|
+
digestNcByNonce[nkey] = (digestNcByNonce[nkey] || 0) + 1;
|
|
795
|
+
hh.authorization = httpDigest.answer(digestChallenge, { method: method, uri: u.pathname + u.search, username: _authUser(opts), password: _authPass(opts), body: body, nc: digestNcByNonce[nkey], policy: _digestPolicy(opts) }, E);
|
|
796
|
+
digestSent = true;
|
|
797
|
+
} else { hh.authorization = authValue; }
|
|
720
798
|
return hh;
|
|
721
799
|
}
|
|
722
800
|
function step() {
|
|
@@ -751,17 +829,65 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
|
751
829
|
return step();
|
|
752
830
|
}
|
|
753
831
|
if (status === 401) {
|
|
754
|
-
|
|
755
|
-
//
|
|
756
|
-
// cross-origin redirect is a different server, and the client MUST NOT send its credentials
|
|
757
|
-
// there (RFC 7030 sec. 3.6 -- credentials go only to an authorized server the caller chose).
|
|
832
|
+
// Answer a challenge ONLY on the origin the caller targeted: a 401 arriving after a cross-origin
|
|
833
|
+
// redirect is a different server, and the client MUST NOT send its credentials there (RFC 7030 sec. 3.6).
|
|
758
834
|
if (url.origin !== initialOrigin) throw E("est/auth-required", "refusing to send HTTP credentials to a redirected origin (RFC 7030 sec. 3.6)");
|
|
759
835
|
var www = String(h["www-authenticate"] || "");
|
|
760
|
-
|
|
761
|
-
|
|
836
|
+
// Bound the attacker-controlled challenge header BEFORE any scheme scan / copy (_offersScheme, the parser):
|
|
837
|
+
// an injected transport without its own header limit must not cause unbounded allocation or work despite
|
|
838
|
+
// the parser's own cap being applied later (CWE-400). The cap matches the Digest parser's.
|
|
839
|
+
if (www.length > constants.LIMITS.HTTP_AUTH_HEADER_MAX_BYTES) throw E("est/auth-required", "the WWW-Authenticate header exceeds the " + constants.LIMITS.HTTP_AUTH_HEADER_MAX_BYTES + "-byte cap (RFC 7030 sec. 3.2.3)");
|
|
840
|
+
var scheme = _authScheme(opts);
|
|
841
|
+
if (scheme === null) throw E("est/auth-required", "the server requires HTTP authentication but no credentials were supplied (RFC 7030 sec. 3.2.3)");
|
|
842
|
+
if (scheme === "digest") {
|
|
843
|
+
if (!_offersScheme(www, "Digest")) throw E("est/auth-required", "opts.auth.scheme is \"digest\" but the server offered no Digest challenge: " + www);
|
|
844
|
+
if (!_hasCreds(opts)) throw E("est/auth-required", "Digest authentication requires opts.auth.username / password (RFC 7030 sec. 3.2.3)");
|
|
845
|
+
// In a credential-rejection context (this request carried a Digest answer), bias selection toward a
|
|
846
|
+
// RETRYABLE stale=true offer so a stronger stale=false offer does not shadow one that could re-answer.
|
|
847
|
+
var selPol = _digestPolicy(opts);
|
|
848
|
+
selPol.preferStale = digestSent;
|
|
849
|
+
selPol.priorNonce = lastDigestNonce; // a retryable stale offer must carry a nonce DIFFERENT from the one just rejected
|
|
850
|
+
selPol.priorRealm = digestChallenge && digestChallenge.realm; // ...and be for the SAME realm that was rejected (a different realm is a new space, not a retry)
|
|
851
|
+
selPol.requestOrigin = url.origin; // prefer an offer whose protection space actually covers THIS request --
|
|
852
|
+
selPol.requestTarget = url.pathname + url.search; // one scoped elsewhere cannot authenticate it and must not shadow one that can
|
|
853
|
+
var ch = httpDigest.parseChallenge(www, E, "est/digest-bad-challenge", selPol);
|
|
854
|
+
if (!ch) throw E("est/auth-required", "the server offered no usable Digest challenge: " + www);
|
|
855
|
+
// Whether this 401 REJECTS a credential turns on the DIRECT signal (RFC 7616 sec. 3.3 / 3.5): did the
|
|
856
|
+
// request that received it actually CARRY a Digest answer for THIS protection space? A request sent
|
|
857
|
+
// unauthenticated -- the first attempt, or one whose target lay outside the cached challenge's domain --
|
|
858
|
+
// has not authenticated, so its 401 (even in the same realm) is a FRESH challenge to answer, never a
|
|
859
|
+
// rejection. A credentialed request refused in the SAME realm IS a rejection of that credential: only a
|
|
860
|
+
// stale=true re-challenge carrying a FRESH nonce may retry (a repeated nonce with stale=true is a
|
|
861
|
+
// self-contradictory / hostile server -> terminate). Any fresh authentication -- the first, a newly
|
|
862
|
+
// entered protection space, or an unauthenticated resource -- is bounded (maxRedirects) against a loop.
|
|
863
|
+
if (digestSent && digestChallenge && ch.realm === digestChallenge.realm) {
|
|
864
|
+
if (!(ch.stale && ch.nonce !== lastDigestNonce && staleRetries < budgets.maxStaleRetries)) throw E("est/auth-required", "the server rejected the credentialed Digest request (RFC 7030 sec. 3.2.3)");
|
|
865
|
+
staleRetries += 1;
|
|
866
|
+
} else {
|
|
867
|
+
if (authTried && authSpaces >= budgets.maxRedirects) throw E("est/auth-required", "the server demanded Digest authentication for too many distinct protection spaces (RFC 7616 sec. 3.3)");
|
|
868
|
+
if (authTried) authSpaces += 1;
|
|
869
|
+
authTried = true;
|
|
870
|
+
staleRetries = 0; // a fresh authentication (new space / unauthenticated resource) restarts the stale budget
|
|
871
|
+
}
|
|
872
|
+
lastDigestNonce = ch.nonce;
|
|
873
|
+
// Cache the CHALLENGE, not a fixed answer: _headersFor computes the response for the CURRENT method /
|
|
874
|
+
// request-target on each hop, so a same-origin redirect (or a 303 POST->GET) gets a target-correct answer
|
|
875
|
+
// rather than a stale one. A policy fault (MD5 / no-qop / unsupported) surfaces from _headersFor on the
|
|
876
|
+
// immediate re-send (the transport is not reached, so a rejected leg still proves calls did not advance).
|
|
877
|
+
// The nonce-count is tracked PER NONCE (digestNcByNonce), so a nonce reissued after other nonces were
|
|
878
|
+
// used in between resumes its own count -- a (nonce, nc) pair is never replayed, without a reset here.
|
|
879
|
+
digestChallenge = ch;
|
|
880
|
+
return step();
|
|
881
|
+
}
|
|
882
|
+
// Basic (default / explicit): the one-shot semantics unchanged.
|
|
883
|
+
if (authTried) throw E("est/auth-required", "the server rejected the credentialed request (RFC 7030 sec. 3.2.3)");
|
|
884
|
+
if (!_offersScheme(www, "Basic")) throw E("est/auth-required", "the server requires an unsupported HTTP authentication scheme (only Basic and Digest are supported): " + www);
|
|
885
|
+
// An explicit auth.scheme:"basic" with no credentials must fail closed too (an empty username stays legal),
|
|
886
|
+
// never transmit a "Basic Og==" (base64 of ":") the operator never supplied.
|
|
887
|
+
if (!_hasCreds(opts)) throw E("est/auth-required", "the server requires HTTP authentication but no credentials were supplied (RFC 7030 sec. 3.2.3)");
|
|
762
888
|
// Establish the credential as origin-scoped state (_headersFor attaches it only on the initial origin),
|
|
763
889
|
// never a mutation of the shared headers that would leak across a cross-origin redirect.
|
|
764
|
-
authValue = "Basic " + Buffer.from((opts
|
|
890
|
+
authValue = "Basic " + Buffer.from((_authUser(opts) || "") + ":" + (_authPass(opts) || ""), "utf8").toString("base64");
|
|
765
891
|
authTried = true;
|
|
766
892
|
return step();
|
|
767
893
|
}
|
|
@@ -794,7 +920,9 @@ function _client(op, method, baseUrl, body, headers, opts) {
|
|
|
794
920
|
timeout: guard.limits.cap(opts.timeout, "timeout", DEFAULT_TIMEOUT, { E: E, code: "est/bad-input", min: 1, max: MAX_TIMEOUT }),
|
|
795
921
|
maxResponseBytes: guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, { E: E, code: "est/bad-input", min: 1, max: constants.LIMITS.HTTP_MAX_RESPONSE_BYTES }),
|
|
796
922
|
maxRedirects: guard.limits.cap(opts.maxRedirects, "maxRedirects", 5, { E: E, code: "est/bad-input", min: 0, max: 32 }),
|
|
923
|
+
maxStaleRetries: guard.limits.cap(opts.auth && opts.auth.maxStaleRetries, "maxStaleRetries", 1, { E: E, code: "est/bad-input", min: 0, max: 8 }),
|
|
797
924
|
};
|
|
925
|
+
_authScheme(opts); // config-time: a bad opts.auth.scheme throws est/bad-input here, never deferred to a 401
|
|
798
926
|
return _drive(method, url, body, Object.assign({}, headers), opts, transport, budgets);
|
|
799
927
|
}
|
|
800
928
|
|
|
@@ -944,10 +1072,205 @@ function simplereenroll(baseUrl, csrInput, opts) {
|
|
|
944
1072
|
});
|
|
945
1073
|
}
|
|
946
1074
|
|
|
1075
|
+
// ---- /serverkeygen (RFC 7030 sec. 4.4) ----------------------------------
|
|
1076
|
+
|
|
1077
|
+
// Derive the server-generated-key ENCRYPTION coherence from the CSR the caller actually POSTs (RFC 7030
|
|
1078
|
+
// sec. 4.4.1): a DecryptKeyIdentifier / AsymmetricDecryptKeyIdentifier attribute names the key-encryption
|
|
1079
|
+
// key the server must encrypt the generated private key to. Binding the expected recipient to the CSR (not a
|
|
1080
|
+
// free-floating opts boolean) closes the drift where a cleartext key rides past a caller who forgot the flag.
|
|
1081
|
+
function _serverkeygenEncryptionFromCsr(csrDer) {
|
|
1082
|
+
var attrs = csr.parse(csrDer).attributes || [];
|
|
1083
|
+
var keyId = null, kind = null;
|
|
1084
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
1085
|
+
if (attrs[i].type !== OID_DECRYPT_KEY_ID && attrs[i].type !== OID_ASYMM_DECRYPT_KEY_ID) continue;
|
|
1086
|
+
// DecryptKeyIdentifier names a SYMMETRIC key-encryption key; AsymmetricDecryptKeyIdentifier an ASYMMETRIC
|
|
1087
|
+
// one (RFC 7030 sec. 4.4.1). The mechanism is preserved so the returned key part is matched to a compatible
|
|
1088
|
+
// RecipientInfo arm, not merely one whose identifier bytes coincide.
|
|
1089
|
+
var thisKind = attrs[i].type === OID_ASYMM_DECRYPT_KEY_ID ? "asymmetric" : "symmetric";
|
|
1090
|
+
var vals = attrs[i].values || [];
|
|
1091
|
+
if (vals.length !== 1) throw E("est/bad-input", "a serverkeygen key-identifier attribute must carry exactly one value (RFC 7030 sec. 4.4.1)");
|
|
1092
|
+
var id;
|
|
1093
|
+
try { id = asn1.read.octetString(asn1.decode(vals[0])); }
|
|
1094
|
+
catch (e) { throw E("est/bad-input", "a serverkeygen key-identifier attribute value is not a valid OCTET STRING", e); }
|
|
1095
|
+
if (keyId !== null && !keyId.equals(id)) throw E("est/bad-input", "the CSR advertised two different serverkeygen key identifiers (RFC 7030 sec. 4.4.1)");
|
|
1096
|
+
if (kind !== null && kind !== thisKind) throw E("est/bad-input", "the CSR advertised both a symmetric (DecryptKeyIdentifier) and an asymmetric (AsymmetricDecryptKeyIdentifier) serverkeygen key -- the recipient mechanism is ambiguous (RFC 7030 sec. 4.4.1)");
|
|
1097
|
+
keyId = id; kind = thisKind;
|
|
1098
|
+
}
|
|
1099
|
+
return { requestedEncryption: keyId !== null, expectedRecipientKeyId: keyId, expectedRecipientKind: kind };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// The delivered private key rides TLS confidentiality alone: a NULL / anonymous / EXPORT suite MUST NOT be
|
|
1103
|
+
// used to carry it (RFC 7030 sec. 4.4, sec. 6). Assert (never reconfigure) the negotiated cipher when the
|
|
1104
|
+
// transport surfaces it; an injected transport that reports no cipher is trusted (the test/loopback channel).
|
|
1105
|
+
function _assertConfidentialCipher(res) {
|
|
1106
|
+
if (!res || !res.tls || !res.tls.cipher) return;
|
|
1107
|
+
var c = res.tls.cipher;
|
|
1108
|
+
var name = (String(c.name || "") + " " + String(c.standardName || "")).toUpperCase();
|
|
1109
|
+
// EXPORT covers the RFC/IANA name; the OpenSSL short names use an EXP prefix followed by a separator OR a
|
|
1110
|
+
// digit run (EXP-RC4-MD5, EXP1024-RC4-SHA), so match EXP before either -- a bare `\bEXP\b` misses EXP1024.
|
|
1111
|
+
if (/NULL|ANON|EXPORT|\bEXP[-_0-9]|\bA(EC)?DH\b/.test(name)) throw E("est/weak-cipher", "the serverkeygen channel negotiated a NULL / anonymous / EXPORT cipher (" + (c.standardName || c.name) + "), which cannot protect the delivered private key (RFC 7030 sec. 4.4)");
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// A case-insensitive header lookup: the injectable transport seam only promises { status, headers, body }, so a
|
|
1115
|
+
// hostile / non-Node transport may deliver "Content-Type" in any casing -- read it case-insensitively (classifyResponse
|
|
1116
|
+
// normalizes internally, but the multipart boundary is read from the header directly here).
|
|
1117
|
+
function _ciHeader(headers, name) {
|
|
1118
|
+
headers = headers || {};
|
|
1119
|
+
if (headers[name] !== undefined) return headers[name];
|
|
1120
|
+
var lname = name.toLowerCase(), keys = Object.keys(headers);
|
|
1121
|
+
for (var i = 0; i < keys.length; i++) { if (keys[i].toLowerCase() === lname) return headers[keys[i]]; }
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async function _serverkeygenResult(res, opts, derived) {
|
|
1126
|
+
var verdict = classifyResponse(res.status, res.headers, res.body, { op: "serverkeygen", now: opts.now });
|
|
1127
|
+
// A 202 is an enrollment posture (RFC 7030 sec. 4.4 treats /serverkeygen as an enroll): surfaced, never slept.
|
|
1128
|
+
if (verdict.status === "retry") return { retry: true, retryAfterSeconds: verdict.retryAfterSeconds, retryAfterDate: verdict.retryAfterDate };
|
|
1129
|
+
if (verdict.status !== "ok") throw E("est/http-error", "an EST serverkeygen response must be HTTP 200 or 202 (RFC 7030 sec. 4.4.2), got " + res.status);
|
|
1130
|
+
var bodyLen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
|
|
1131
|
+
if (bodyLen === 0) throw E("est/empty-body", "a 200 serverkeygen response carried an empty body (RFC 7030 sec. 4.4.2)");
|
|
1132
|
+
_assertConfidentialCipher(res);
|
|
1133
|
+
// parseServerKeygenResponse does the per-part transfer-decode; the recipient coherence is bound to the CSR.
|
|
1134
|
+
var out = parseServerKeygenResponse(res.body, _ciHeader(res.headers, "content-type"), {
|
|
1135
|
+
requestedEncryption: derived.requestedEncryption,
|
|
1136
|
+
expectedRecipientKeyId: derived.expectedRecipientKeyId,
|
|
1137
|
+
expectedRecipientKind: derived.expectedRecipientKind,
|
|
1138
|
+
expectedRecipientIssuerSerial: opts.expectedRecipientIssuerSerial,
|
|
1139
|
+
});
|
|
1140
|
+
// Bind a CLEARTEXT server-generated key to its certificate (RFC 7030 sec. 4.4.2): the CA generated
|
|
1141
|
+
// this pair and issued a certificate over its PUBLIC half, so the delivered PrivateKeyInfo's public key
|
|
1142
|
+
// MUST equal EXACTLY ONE returned certificate's SubjectPublicKeyInfo -- a key unrelated to every
|
|
1143
|
+
// certificate is an unusable / mis-associated credential, and a key matching more than one leaves the
|
|
1144
|
+
// issued certificate ambiguous. Mirrors simpleenroll's findIssuedCert public-key match; the encrypted
|
|
1145
|
+
// key stays opaque, so its recipient coherence is bound in the parser instead. The public half is
|
|
1146
|
+
// derived through the key engine (never re-serialized here); a key whose public half cannot be derived
|
|
1147
|
+
// is not bindable and fails closed.
|
|
1148
|
+
if (out.privateKeyDer) {
|
|
1149
|
+
var spki;
|
|
1150
|
+
try { spki = await key.publicFromPrivate(out.privateKeyDer); }
|
|
1151
|
+
catch (e) { throw E("est/key-cert-mismatch", "the cleartext server-generated private key's public half could not be derived to bind it to a returned certificate (RFC 7030 sec. 4.4.2)", e); }
|
|
1152
|
+
var bound = findIssuedCert(out.certificates, spki);
|
|
1153
|
+
if (!bound) throw E("est/key-cert-mismatch", "the cleartext server-generated private key matches no returned certificate's public key (RFC 7030 sec. 4.4.2)");
|
|
1154
|
+
if (findIssuedCert(out.certificates.filter(function (c) { return c !== bound; }), spki)) throw E("est/ambiguous-issued-cert", "more than one returned certificate carries the server-generated key; the issued certificate is ambiguous (RFC 7030 sec. 4.4.2)");
|
|
1155
|
+
delete out.privateKeyDer;
|
|
1156
|
+
}
|
|
1157
|
+
return out;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* @primitive pki.est.serverkeygen
|
|
1162
|
+
* @signature pki.est.serverkeygen(baseUrl, csr, opts?) -> Promise<{ certificates, privateKey } | { certificates, encryptedKey } | { retry, retryAfterSeconds, retryAfterDate }>
|
|
1163
|
+
* @since 0.3.28
|
|
1164
|
+
* @status experimental
|
|
1165
|
+
* @spec RFC 7030, RFC 8951
|
|
1166
|
+
* @related pki.est.simpleenroll, pki.est.parseServerKeygenResponse
|
|
1167
|
+
*
|
|
1168
|
+
* Request a SERVER-GENERATED key pair + certificate: POST the CSR (base64 DER, `Content-Type:
|
|
1169
|
+
* application/pkcs10`, identical request encoding to `simpleenroll`) to
|
|
1170
|
+
* `<baseUrl>/.well-known/est/serverkeygen`. The two-part `multipart/mixed` response is surfaced as
|
|
1171
|
+
* `{ certificates, privateKey }` (a cleartext PKCS#8 `PrivateKeyInfo`) or `{ certificates,
|
|
1172
|
+
* encryptedKey }` (the CMS `EnvelopedData` the caller decrypts out-of-band with its key-encryption key --
|
|
1173
|
+
* the verb NEVER decrypts, so it is not a decryption oracle), or `{ retry, retryAfterSeconds }` on a 202.
|
|
1174
|
+
* The certificates are RAW/unordered -- unlike `simpleenroll` no leaf is picked, because the CA generated
|
|
1175
|
+
* the key so the issued certificate's public key is the generated one, not the throwaway CSR key. A CLEARTEXT
|
|
1176
|
+
* key is bound to its certificate before it resolves: the delivered private key's public half MUST match
|
|
1177
|
+
* EXACTLY ONE returned certificate (`est/key-cert-mismatch` on none, `est/ambiguous-issued-cert` on more than
|
|
1178
|
+
* one), so a mis-associated key is refused rather than handed back unusable.
|
|
1179
|
+
* The encryption requirement + expected recipient are DERIVED from the CSR's own DecryptKeyIdentifier /
|
|
1180
|
+
* AsymmetricDecryptKeyIdentifier attribute; an `opts` value that contradicts the CSR is `est/bad-input`
|
|
1181
|
+
* (a cleartext-key downgrade cannot slip past). The delivered key's channel is asserted confidentiality-
|
|
1182
|
+
* bearing (a NULL / anonymous / EXPORT cipher is `est/weak-cipher`). https-only, explicit-anchor, and the
|
|
1183
|
+
* whole redirect / auth / budget machinery of `simpleenroll` apply.
|
|
1184
|
+
*
|
|
1185
|
+
* @opts
|
|
1186
|
+
* - `requestedEncryption` / `expectedRecipientKeyId` / `expectedRecipientIssuerSerial` -- OPTIONAL
|
|
1187
|
+
* overrides of the CSR-derived recipient coherence; a value that contradicts the CSR is `est/bad-input`.
|
|
1188
|
+
* - every option of pki.est.simpleenroll (transport, tls, label, budgets, credentials incl. `auth`).
|
|
1189
|
+
* @example
|
|
1190
|
+
* var r = await pki.est.serverkeygen("https://ca.example", csrDer,
|
|
1191
|
+
* { transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
|
|
1192
|
+
* r.retry; // true -- a 202 is surfaced, never slept
|
|
1193
|
+
*/
|
|
1194
|
+
function serverkeygen(baseUrl, csrInput, opts) {
|
|
1195
|
+
opts = opts || {};
|
|
1196
|
+
return Promise.resolve().then(function () {
|
|
1197
|
+
var csrDer = _csrDer(csrInput);
|
|
1198
|
+
var derived = _serverkeygenEncryptionFromCsr(csrDer);
|
|
1199
|
+
if (opts.requestedEncryption !== undefined && !!opts.requestedEncryption !== derived.requestedEncryption) throw E("est/bad-input", "opts.requestedEncryption (" + !!opts.requestedEncryption + ") contradicts the CSR's advertised key-encryption attribute (" + derived.requestedEncryption + ") (RFC 7030 sec. 4.4.1)");
|
|
1200
|
+
if (opts.expectedRecipientKeyId !== undefined) {
|
|
1201
|
+
if (!Buffer.isBuffer(opts.expectedRecipientKeyId)) throw E("est/bad-input", "opts.expectedRecipientKeyId must be a Buffer");
|
|
1202
|
+
if (derived.expectedRecipientKeyId && !opts.expectedRecipientKeyId.equals(derived.expectedRecipientKeyId)) throw E("est/bad-input", "opts.expectedRecipientKeyId contradicts the key identifier the CSR advertised (RFC 7030 sec. 4.4.1)");
|
|
1203
|
+
}
|
|
1204
|
+
if (opts.expectedRecipientIssuerSerial != null) {
|
|
1205
|
+
var eis = opts.expectedRecipientIssuerSerial;
|
|
1206
|
+
if (typeof eis !== "object" || Buffer.isBuffer(eis) || !Buffer.isBuffer(eis.issuer)) throw E("est/bad-input", "opts.expectedRecipientIssuerSerial must be { issuer: Buffer, serialNumber }");
|
|
1207
|
+
var s = eis.serialNumber;
|
|
1208
|
+
if (!((typeof s === "bigint" && s >= 0n) || (typeof s === "number" && Number.isSafeInteger(s) && s >= 0) || (typeof s === "string" && /^[0-9]+$/.test(s)))) throw E("est/bad-input", "opts.expectedRecipientIssuerSerial.serialNumber must be a NON-NEGATIVE bigint, a safe non-negative integer, or a decimal digit string (a certificate serial is non-negative, RFC 5280 sec. 4.1.2.2)");
|
|
1209
|
+
}
|
|
1210
|
+
// A recipient expectation implies the key part MUST be encrypted to that recipient; if the CSR advertised no
|
|
1211
|
+
// key-encryption attribute the coherence check below never runs, so a compromised CA could deliver the key
|
|
1212
|
+
// to a recipient IT controls (or cleartext) while the caller believes its pin was enforced. Refuse the
|
|
1213
|
+
// contradiction at config time (RFC 7030 sec. 4.4.1) -- the caller must advertise the KEK in the CSR.
|
|
1214
|
+
if ((opts.expectedRecipientKeyId !== undefined || opts.expectedRecipientIssuerSerial != null) && !derived.requestedEncryption) {
|
|
1215
|
+
throw E("est/bad-input", "a recipient expectation (expectedRecipientKeyId / expectedRecipientIssuerSerial) implies an encrypted key, but the CSR advertised no DecryptKeyIdentifier / AsymmetricDecryptKeyIdentifier attribute (RFC 7030 sec. 4.4.1)");
|
|
1216
|
+
}
|
|
1217
|
+
return _client("serverkeygen", "POST", baseUrl, transferEncode(csrDer), { accept: "multipart/mixed", "content-type": "application/pkcs10" }, opts)
|
|
1218
|
+
.then(function (res) { return _serverkeygenResult(res, opts, derived); });
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// ---- /csrattrs GET (RFC 7030 sec. 4.5, RFC 9908) ------------------------
|
|
1223
|
+
|
|
1224
|
+
function _csrattrsResult(res, opts) {
|
|
1225
|
+
var verdict = classifyResponse(res.status, res.headers, res.body, { op: "csrattrs", now: opts.now });
|
|
1226
|
+
// 204 / 404 = "CSR Attributes Response not available" -- a valid NONE outcome, not an error (RFC 7030 sec. 4.5.2).
|
|
1227
|
+
if (verdict.status === "none-available") return { available: false, attrs: null };
|
|
1228
|
+
// A 202 is nonconforming for a policy GET (sec. 4.5.2 lists only 200 / 204 / 404).
|
|
1229
|
+
if (verdict.status === "retry") throw E("est/http-error", "a /csrattrs response must be HTTP 200, 204, or 404, not 202 (RFC 7030 sec. 4.5.2)");
|
|
1230
|
+
if (verdict.status !== "ok") throw E("est/http-error", "an EST csrattrs response must be HTTP 200 / 204 / 404 (RFC 7030 sec. 4.5.2), got " + res.status);
|
|
1231
|
+
var bodyLen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
|
|
1232
|
+
// An empty HTTP body is distinct from a valid EMPTY CsrAttrs (`30 00` / base64 `MAA=`), which parses as {items:[]}.
|
|
1233
|
+
if (bodyLen === 0) throw E("est/empty-body", "a 200 csrattrs response carried an empty body (RFC 7030 sec. 4.5.2)");
|
|
1234
|
+
var attrs = csrattrsFmt.parse(transferDecode(res.body));
|
|
1235
|
+
return { available: true, attrs: attrs, plan: buildEnrollAttributes(attrs) };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* @primitive pki.est.csrattrs
|
|
1240
|
+
* @signature pki.est.csrattrs(baseUrl, opts?) -> Promise<{ available: true, attrs, plan } | { available: false, attrs: null }>
|
|
1241
|
+
* @since 0.3.28
|
|
1242
|
+
* @status experimental
|
|
1243
|
+
* @spec RFC 7030, RFC 8951, RFC 9908
|
|
1244
|
+
* @related pki.est.simpleenroll, pki.est.buildEnrollAttributes
|
|
1245
|
+
*
|
|
1246
|
+
* Fetch the CA's CSR-attributes policy: GET `<baseUrl>/.well-known/est/csrattrs` (`Accept:
|
|
1247
|
+
* application/csrattrs`). A 200 body is base64-decoded, parsed as an RFC 9908 `CsrAttrs`, and returned
|
|
1248
|
+
* with a `plan` (`buildEnrollAttributes`) the caller applies to its NEXT CSR -- the verb NEVER auto-applies
|
|
1249
|
+
* attributes to a CSR (single responsibility). A 204 or 404 is `{ available: false }` (a valid "no specific
|
|
1250
|
+
* attributes"), NOT an error; an empty SEQUENCE (`30 00`) is a COMPLETE empty policy (`attrs.items` empty),
|
|
1251
|
+
* distinct from an empty HTTP body (`est/empty-body`). Server auth is NOT required for this policy GET but a
|
|
1252
|
+
* 401 is tolerated (the shared auth path stays live). https-only + explicit-anchor as elsewhere.
|
|
1253
|
+
*
|
|
1254
|
+
* @opts
|
|
1255
|
+
* - every transport / tls / label / budget / credential option of the other verbs.
|
|
1256
|
+
* @example
|
|
1257
|
+
* var r = await pki.est.csrattrs("https://ca.example",
|
|
1258
|
+
* { transport: function () { return Promise.resolve({ status: 404, headers: {}, body: "" }); } });
|
|
1259
|
+
* r.available; // false -- a 404 is "no CSR-attributes policy available"
|
|
1260
|
+
*/
|
|
1261
|
+
function csrattrs(baseUrl, opts) {
|
|
1262
|
+
opts = opts || {};
|
|
1263
|
+
return Promise.resolve().then(function () {
|
|
1264
|
+
return _client("csrattrs", "GET", baseUrl, null, { accept: "application/csrattrs" }, opts);
|
|
1265
|
+
}).then(function (res) { return _csrattrsResult(res, opts); });
|
|
1266
|
+
}
|
|
1267
|
+
|
|
947
1268
|
module.exports = {
|
|
948
1269
|
cacerts: cacerts,
|
|
949
1270
|
simpleenroll: simpleenroll,
|
|
950
1271
|
simplereenroll: simplereenroll,
|
|
1272
|
+
serverkeygen: serverkeygen,
|
|
1273
|
+
csrattrs: csrattrs,
|
|
951
1274
|
transferDecode: transferDecode,
|
|
952
1275
|
transferEncode: transferEncode,
|
|
953
1276
|
splitMultipartMixed: splitMultipartMixed,
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- NOT on pki.*. HTTP Digest access authentication (RFC 7616): parse an UNTRUSTED
|
|
6
|
+
// WWW-Authenticate challenge (fail-closed, quoted-string-honoring) and compute the Authorization
|
|
7
|
+
// response header (byte-exact A1/A2/KD). Prefix-agnostic: every thrown code comes from the caller's
|
|
8
|
+
// E(code, msg) FACTORY (never a defineClass class -- feedback_guard_error_factory_not_class) and its
|
|
9
|
+
// policy.codes map, so pki.est composes it now and pki.acme / pki.cmp can reuse it as config, not a fork.
|
|
10
|
+
//
|
|
11
|
+
// The challenge is attacker-shaped: a comma inside a quoted value is NOT a delimiter (the _hasBasicChallenge
|
|
12
|
+
// substring-scan bug class), a missing realm / nonce is REJECTED not defaulted, and an unsupported / weak
|
|
13
|
+
// algorithm or a no-qop challenge fails closed rather than downgrading (feedback_guards_fail_closed_not_guess).
|
|
14
|
+
|
|
15
|
+
var crypto = require("crypto");
|
|
16
|
+
var constants = require("./constants");
|
|
17
|
+
|
|
18
|
+
// Algorithm registry (a data row, not a switch -- Hard rule #2): rank orders the RFC 7616 sec. 3.7
|
|
19
|
+
// "most secure the client can use" selection (SHA-512-256 > SHA-256 > MD5); an unknown algorithm ranks 0.
|
|
20
|
+
var ALGS = {
|
|
21
|
+
"SHA-512-256": { hash: "sha512-256", rank: 3, sess: false }, "SHA-512-256-SESS": { hash: "sha512-256", rank: 3, sess: true },
|
|
22
|
+
"SHA-256": { hash: "sha256", rank: 2, sess: false }, "SHA-256-SESS": { hash: "sha256", rank: 2, sess: true },
|
|
23
|
+
"MD5": { hash: "md5", rank: 1, sess: false }, "MD5-SESS": { hash: "md5", rank: 1, sess: true },
|
|
24
|
+
};
|
|
25
|
+
var DEFAULT_CODES = {
|
|
26
|
+
unsupportedAlgorithm: "digest/unsupported-algorithm", weakAlgorithm: "digest/weak-algorithm",
|
|
27
|
+
noQop: "digest/no-qop", badChallenge: "digest/bad-challenge",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// H = lowercase-hex digest; the octet string is fed as latin1 so a UTF-8-encoded credential (charset=UTF-8)
|
|
31
|
+
// is hashed over its exact bytes. KD(secret, data) = H(secret ":" data).
|
|
32
|
+
function H(hash, s) { return crypto.createHash(hash).update(s, "latin1").digest("hex"); }
|
|
33
|
+
function KD(hash, secret, data) { return H(hash, secret + ":" + data); }
|
|
34
|
+
function _qstr(v) { return "\"" + String(v).replace(/(["\\])/g, "\\$1") + "\""; }
|
|
35
|
+
|
|
36
|
+
// Split a header at commas that are OUTSIDE a quoted-string (a quoted comma is a literal, not a delimiter).
|
|
37
|
+
function _commaSplitOutsideQuotes(s) {
|
|
38
|
+
var out = [], buf = "", inQ = false, esc = false;
|
|
39
|
+
for (var i = 0; i < s.length; i++) {
|
|
40
|
+
var c = s.charAt(i);
|
|
41
|
+
if (esc) { buf += c; esc = false; continue; }
|
|
42
|
+
if (inQ && c === "\\") { buf += c; esc = true; continue; }
|
|
43
|
+
if (c === "\"") { inQ = !inQ; buf += c; continue; }
|
|
44
|
+
if (c === "," && !inQ) { out.push(buf); buf = ""; continue; }
|
|
45
|
+
buf += c;
|
|
46
|
+
}
|
|
47
|
+
out.push(buf);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
// Index of the first '=' OUTSIDE a quoted-string, or -1.
|
|
51
|
+
function _firstEqOutsideQuotes(s) {
|
|
52
|
+
var inQ = false, esc = false;
|
|
53
|
+
for (var i = 0; i < s.length; i++) {
|
|
54
|
+
var c = s.charAt(i);
|
|
55
|
+
if (esc) { esc = false; continue; }
|
|
56
|
+
if (inQ && c === "\\") { esc = true; continue; }
|
|
57
|
+
if (c === "\"") { inQ = !inQ; continue; }
|
|
58
|
+
if (c === "=" && !inQ) return i;
|
|
59
|
+
}
|
|
60
|
+
return -1;
|
|
61
|
+
}
|
|
62
|
+
function _unq(s) { return s.slice(1, -1).replace(/\\(.)/g, "$1"); } // only ever called on a well-formed quoted-string (see _closedQuote)
|
|
63
|
+
// A well-formed quoted-string: opens with ", closes with an UNESCAPED " that is the LAST character (nothing trails
|
|
64
|
+
// it). An unterminated / trailing-garbage quoted-string is malformed (RFC 7230 sec. 3.2.6) and rejected.
|
|
65
|
+
function _closedQuote(s) {
|
|
66
|
+
if (s.length < 2 || s.charAt(0) !== "\"") return false;
|
|
67
|
+
var esc = false;
|
|
68
|
+
for (var i = 1; i < s.length; i++) {
|
|
69
|
+
var c = s.charAt(i);
|
|
70
|
+
if (esc) { esc = false; continue; }
|
|
71
|
+
if (c === "\\") { esc = true; continue; }
|
|
72
|
+
if (c === "\"") return i === s.length - 1;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
// A control octet (RFC 7230 sec. 3.2.6 quoted-string / qdtext forbids CTL except HTAB). Scanned with charCodeAt
|
|
77
|
+
// rather than a control-char regex literal (which eslint no-control-regex correctly refuses).
|
|
78
|
+
function _hasCtl(s) {
|
|
79
|
+
for (var i = 0; i < s.length; i++) { var c = s.charCodeAt(i); if ((c < 0x20 && c !== 0x09) || c === 0x7f) return true; }
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Split a WWW-Authenticate header into [{ scheme, paramText }]. A comma-separated segment whose pre-'='
|
|
84
|
+
// text is "token WS token" (a scheme name before the first auth-param key), or a bare token with no '=',
|
|
85
|
+
// STARTS a new challenge; a "key=value" segment is a param continuation of the current challenge.
|
|
86
|
+
function _splitChallenges(s) {
|
|
87
|
+
var segs = _commaSplitOutsideQuotes(s);
|
|
88
|
+
var out = [], cur = null;
|
|
89
|
+
for (var i = 0; i < segs.length; i++) {
|
|
90
|
+
var seg = segs[i].replace(/^\s+|\s+$/g, "");
|
|
91
|
+
if (seg === "") continue;
|
|
92
|
+
var eq = _firstEqOutsideQuotes(seg);
|
|
93
|
+
var pre = (eq < 0 ? seg : seg.slice(0, eq)).replace(/^\s+|\s+$/g, "");
|
|
94
|
+
var ws = /^(\S+)\s+(\S[\s\S]*)$/.exec(pre);
|
|
95
|
+
if (eq < 0) { cur = { scheme: seg, paramText: "" }; out.push(cur); } // a bare scheme token (e.g. "Basic")
|
|
96
|
+
else if (ws) { cur = { scheme: ws[1], paramText: seg.replace(/^\s*\S+\s+/, "") }; out.push(cur); } // "scheme firstKey=..."
|
|
97
|
+
else if (cur) { cur.paramText = cur.paramText ? cur.paramText + "," + seg : seg; } // "key=value" continuation
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
// Parse a challenge's params into { key(lower): { value, quoted } }; a repeated key is malformed (fail closed).
|
|
102
|
+
function _parseParams(paramText, E, code) {
|
|
103
|
+
var segs = _commaSplitOutsideQuotes(paramText), map = Object.create(null);
|
|
104
|
+
for (var i = 0; i < segs.length; i++) {
|
|
105
|
+
var seg = segs[i].replace(/^\s+|\s+$/g, "");
|
|
106
|
+
if (seg === "") continue;
|
|
107
|
+
var eq = _firstEqOutsideQuotes(seg);
|
|
108
|
+
if (eq < 0) throw E(code, "malformed Digest auth-param (no '='): " + JSON.stringify(seg));
|
|
109
|
+
var key = seg.slice(0, eq).replace(/^\s+|\s+$/g, "").toLowerCase();
|
|
110
|
+
var rawVal = seg.slice(eq + 1).replace(/^\s+|\s+$/g, "");
|
|
111
|
+
var quoted = rawVal.charAt(0) === "\"";
|
|
112
|
+
if (quoted && !_closedQuote(rawVal)) throw E(code, "an unterminated or trailing-garbage Digest quoted-string (RFC 7230 sec. 3.2.6)");
|
|
113
|
+
if (Object.prototype.hasOwnProperty.call(map, key)) throw E(code, "repeated Digest auth-param " + JSON.stringify(key));
|
|
114
|
+
var value = quoted ? _unq(rawVal) : rawVal;
|
|
115
|
+
// Reject a control octet in any value (CR / LF / NUL / ...): it is a MUST-reject per RFC 7230 qdtext AND a
|
|
116
|
+
// header-injection sink -- an unescaped CR/LF reflected into the outgoing Authorization would split the header.
|
|
117
|
+
if (_hasCtl(value)) throw E(code, "a Digest auth-param value contains a control character (RFC 7230 sec. 3.2.6)");
|
|
118
|
+
map[key] = { value: value, quoted: quoted };
|
|
119
|
+
}
|
|
120
|
+
return map;
|
|
121
|
+
}
|
|
122
|
+
// Structural validation of ONE Digest challenge's params (RFC 7616 sec. 3.3). Throws on any violation.
|
|
123
|
+
function _validateDigest(paramText, E, code) {
|
|
124
|
+
var p = _parseParams(paramText, E, code);
|
|
125
|
+
if (!p.realm || !p.realm.quoted || p.realm.value === "") throw E(code, "a Digest challenge requires a non-empty quoted realm (RFC 7616 sec. 3.3)");
|
|
126
|
+
if (!p.nonce || !p.nonce.quoted || p.nonce.value === "") throw E(code, "a Digest challenge requires a non-empty quoted nonce (RFC 7616 sec. 3.3)");
|
|
127
|
+
if (p.algorithm && p.algorithm.quoted) throw E(code, "the Digest algorithm must be a token, not a quoted-string (RFC 7616 sec. 3.3)");
|
|
128
|
+
var qop = [];
|
|
129
|
+
if (p.qop) {
|
|
130
|
+
if (!p.qop.quoted) throw E(code, "the Digest qop must be a quoted list (RFC 7616 sec. 3.3)");
|
|
131
|
+
qop = p.qop.value.split(",").map(function (x) { return x.replace(/^\s+|\s+$/g, "").toLowerCase(); }).filter(Boolean);
|
|
132
|
+
// A qop directive that is PRESENT but lists no value (qop="" / qop=", ,") is malformed -- it is NOT the
|
|
133
|
+
// absent-qop (RFC 2069) case. Reject it rather than silently collapsing to a no-qop response the server
|
|
134
|
+
// cannot accept (RFC 7616 sec. 3.3). Only an OMITTED qop directive selects the legacy no-qop path.
|
|
135
|
+
if (qop.length === 0) throw E(code, "a present Digest qop directive must list at least one value (RFC 7616 sec. 3.3)");
|
|
136
|
+
}
|
|
137
|
+
// domain (RFC 7616 sec. 3.3): a QUOTED, space-separated list of URIs defining the protection space. An
|
|
138
|
+
// UNQUOTED domain is malformed and rejected (fail closed like realm / nonce / qop) rather than silently
|
|
139
|
+
// widened -- widening a mis-encoded scope to "the whole server" would send credentials MORE broadly than the
|
|
140
|
+
// server intended. Surfaced as an array of the listed URIs, or null when omitted / quoted-empty -- which per
|
|
141
|
+
// the RFC means "all URIs on the responding server" (the answer may be reused for any same-origin URI, sec. 3.5).
|
|
142
|
+
if (p.domain && !p.domain.quoted) throw E(code, "the Digest domain must be a quoted-string (RFC 7616 sec. 3.3)");
|
|
143
|
+
var domain = (p.domain && p.domain.value.replace(/^\s+|\s+$/g, "") !== "")
|
|
144
|
+
? p.domain.value.replace(/^\s+|\s+$/g, "").split(/\s+/) : null;
|
|
145
|
+
// charset (RFC 7616 sec. 3.3): the ONLY permitted value is the UNQUOTED token "UTF-8". A quoted charset, or any
|
|
146
|
+
// other value, is malformed -- answering it would hash the credentials in the wrong encoding -- so it is
|
|
147
|
+
// rejected (and thus skipped during multi-offer selection in favour of a conforming offer).
|
|
148
|
+
if (p.charset && (p.charset.quoted || String(p.charset.value).toUpperCase() !== "UTF-8")) throw E(code, "the Digest charset must be the unquoted token UTF-8 (RFC 7616 sec. 3.3)");
|
|
149
|
+
// stale (RFC 7616 sec. 3.3) is the unquoted token "true" or "false". A quoted or otherwise invalid value
|
|
150
|
+
// (stale=maybe) is malformed and rejected (thus skipped in selection) rather than parsed as a false flag that
|
|
151
|
+
// could shadow a conforming offer.
|
|
152
|
+
if (p.stale) {
|
|
153
|
+
var sv = String(p.stale.value).toLowerCase();
|
|
154
|
+
if (p.stale.quoted || (sv !== "true" && sv !== "false")) throw E(code, "the Digest stale directive must be the unquoted token true or false (RFC 7616 sec. 3.3)");
|
|
155
|
+
}
|
|
156
|
+
// userhash (RFC 7616 sec. 3.3) is likewise the unquoted token "true" or "false". A quoted or otherwise invalid
|
|
157
|
+
// value is malformed and rejected (skipped in selection) rather than silently changing how the username is sent.
|
|
158
|
+
if (p.userhash) {
|
|
159
|
+
var uhv = String(p.userhash.value).toLowerCase();
|
|
160
|
+
if (p.userhash.quoted || (uhv !== "true" && uhv !== "false")) throw E(code, "the Digest userhash directive must be the unquoted token true or false (RFC 7616 sec. 3.3)");
|
|
161
|
+
}
|
|
162
|
+
// opaque (RFC 7616 sec. 3.3) is a quoted-string echoed back verbatim. An UNQUOTED opaque is malformed and
|
|
163
|
+
// rejected (skipped in selection) rather than accepted, where it could shadow a conforming offer.
|
|
164
|
+
if (p.opaque && !p.opaque.quoted) throw E(code, "the Digest opaque directive must be a quoted-string (RFC 7616 sec. 3.3)");
|
|
165
|
+
return {
|
|
166
|
+
scheme: "Digest", realm: p.realm.value, nonce: p.nonce.value, qop: qop, domain: domain,
|
|
167
|
+
algorithm: p.algorithm ? p.algorithm.value.toUpperCase() : "MD5",
|
|
168
|
+
opaque: p.opaque ? p.opaque.value : null,
|
|
169
|
+
stale: !!(p.stale && String(p.stale.value).toLowerCase() === "true"),
|
|
170
|
+
userhash: !!(p.userhash && String(p.userhash.value).toLowerCase() === "true"),
|
|
171
|
+
charset: p.charset ? p.charset.value : null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// parseChallenge(www, E, code, policy?) -> the most-secure USABLE structurally-valid Digest challenge,
|
|
176
|
+
// OR null (no Digest challenge present), OR throw E(code) (a Digest challenge present but none valid).
|
|
177
|
+
// Selection is policy-aware: a challenge answer() would refuse under the active policy (an unsupported /
|
|
178
|
+
// MD5-when-disallowed algorithm, or a qop this client cannot satisfy) is ranked BELOW every usable one, so a
|
|
179
|
+
// higher-algorithm-rank but unusable offer (e.g. a SHA-512-256 no-qop challenge under the default policy)
|
|
180
|
+
// never shadows a lower-ranked usable one (e.g. SHA-256 with qop="auth") -- RFC 7616 sec. 3.3. When NO
|
|
181
|
+
// challenge is usable the highest-algorithm-rank one is still returned, so answer() reports the specific
|
|
182
|
+
// policy reason (which opt to set) rather than a generic "no challenge".
|
|
183
|
+
function parseChallenge(www, E, code, policy) {
|
|
184
|
+
var pol = policy || {};
|
|
185
|
+
var codes = pol.codes || DEFAULT_CODES;
|
|
186
|
+
// preferStale marks a CREDENTIAL-REJECTION context (a 401 answering a credentialed request in the same space):
|
|
187
|
+
// there, a stale=true offer is RETRYABLE with a fresh nonce while a stale=false offer is a terminal rejection,
|
|
188
|
+
// so a retryable offer must outrank a stronger non-retryable one (RFC 7616 sec. 3.3). In the initial context
|
|
189
|
+
// it is not set and stale plays no part.
|
|
190
|
+
var preferStale = !!pol.preferStale;
|
|
191
|
+
var raw = String(www == null ? "" : www);
|
|
192
|
+
if (raw.length > constants.LIMITS.HTTP_AUTH_HEADER_MAX_BYTES) throw E(code, "the WWW-Authenticate header exceeds the " + constants.LIMITS.HTTP_AUTH_HEADER_MAX_BYTES + "-byte cap");
|
|
193
|
+
var challenges = _splitChallenges(raw);
|
|
194
|
+
var best = null, bestUsable = false, bestApplicable = false, bestStale = false, bestRank = -1, sawDigest = false;
|
|
195
|
+
for (var i = 0; i < challenges.length; i++) {
|
|
196
|
+
if (challenges[i].scheme.toLowerCase() !== "digest") continue;
|
|
197
|
+
sawDigest = true;
|
|
198
|
+
var parsed;
|
|
199
|
+
// A MALFORMED offer is skipped, not fatal: parseChallenge's contract is to throw only when NO valid Digest
|
|
200
|
+
// offer exists (RFC 7616 sec. 3.3), so a bad offer alongside a good one still authenticates on the good one.
|
|
201
|
+
try { parsed = _validateDigest(challenges[i].paramText, E, code); }
|
|
202
|
+
catch (_e) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
var alg = ALGS[parsed.algorithm];
|
|
206
|
+
var rank = alg ? alg.rank : 0;
|
|
207
|
+
var usable = _rejection(parsed, pol, codes) === null;
|
|
208
|
+
// APPLICABLE: the offer's own protection space covers the CURRENT request target (RFC 7616 sec. 3.5). An
|
|
209
|
+
// offer whose `domain` excludes this request cannot authenticate it (the caller would omit the credential),
|
|
210
|
+
// so it must not shadow a weaker offer that does apply. Only checked when the caller supplies the request
|
|
211
|
+
// context; otherwise every offer is treated as applicable.
|
|
212
|
+
var applicable = pol.requestTarget === undefined ? true : inProtectionSpace(parsed, pol.requestOrigin, pol.requestTarget);
|
|
213
|
+
// A RETRYABLE offer (only meaningful in the rejection context) is stale=true, carries a FRESH nonce, AND is
|
|
214
|
+
// for the SAME realm whose credential was just rejected -- the conditions under which the caller re-answers.
|
|
215
|
+
// A stale offer for a DIFFERENT realm is a new protection space (not a retry), and a stale offer repeating
|
|
216
|
+
// the prior nonce cannot re-answer, so neither must out-rank a weaker genuinely retryable offer (RFC 7616 sec. 3.3).
|
|
217
|
+
var retryable = preferStale && !!parsed.stale && parsed.nonce !== pol.priorNonce && parsed.realm === pol.priorRealm;
|
|
218
|
+
// Usability dominates; then an offer that APPLIES to this request; then (in a rejection) a retryable offer;
|
|
219
|
+
// then the stronger algorithm.
|
|
220
|
+
if (best === null ||
|
|
221
|
+
(usable && !bestUsable) ||
|
|
222
|
+
(usable === bestUsable && applicable && !bestApplicable) ||
|
|
223
|
+
(usable === bestUsable && applicable === bestApplicable && retryable && !bestStale) ||
|
|
224
|
+
(usable === bestUsable && applicable === bestApplicable && retryable === bestStale && rank > bestRank)) {
|
|
225
|
+
best = parsed; bestUsable = usable; bestApplicable = applicable; bestStale = retryable; bestRank = rank;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (best) return best;
|
|
229
|
+
if (sawDigest) throw E(code, "no valid Digest challenge: every Digest offer was malformed (missing realm / nonce or a bad directive, RFC 7616 sec. 3.3)");
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Credential octets for the given charset (RFC 7616 sec. 4): UTF-8 -> the exact UTF-8 bytes; else the
|
|
234
|
+
// string as-is (ASCII / latin1). H() hashes over latin1, so a UTF-8 credential is fed as its octet string.
|
|
235
|
+
function _octets(s, charset) {
|
|
236
|
+
var v = s == null ? "" : String(s);
|
|
237
|
+
return (charset && String(charset).toUpperCase() === "UTF-8") ? Buffer.from(v, "utf8").toString("latin1") : v;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Does the string carry any non-ASCII code unit (> U+007F)? Such a UTF-8 username is sent via `username*`.
|
|
241
|
+
function _hasNonAscii(s) {
|
|
242
|
+
s = String(s == null ? "" : s);
|
|
243
|
+
for (var i = 0; i < s.length; i++) { if (s.charCodeAt(i) > 0x7F) return true; }
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// RFC 5987 / RFC 8187 percent-encoding of a string's UTF-8 octets: attr-char (ALPHA / DIGIT / "!#$&+-.^_`|~")
|
|
248
|
+
// is kept literal, every other octet is %XX (upper-hex). Used for the Digest `username*` extended value.
|
|
249
|
+
function _pctEncodeUtf8(s) {
|
|
250
|
+
var bytes = Buffer.from(String(s == null ? "" : s), "utf8");
|
|
251
|
+
var out = "";
|
|
252
|
+
for (var i = 0; i < bytes.length; i++) {
|
|
253
|
+
var b = bytes[i];
|
|
254
|
+
if ((b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A) ||
|
|
255
|
+
b === 0x21 || b === 0x23 || b === 0x24 || b === 0x26 || b === 0x2B || b === 0x2D ||
|
|
256
|
+
b === 0x2E || b === 0x5E || b === 0x5F || b === 0x60 || b === 0x7C || b === 0x7E) {
|
|
257
|
+
out += String.fromCharCode(b);
|
|
258
|
+
} else {
|
|
259
|
+
out += "%" + (b < 0x10 ? "0" : "") + b.toString(16).toUpperCase();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The policy gates a parsed challenge must clear to be answerable (RFC 7616 sec. 3.4): a supported
|
|
266
|
+
// algorithm, MD5 only when allowed, and a qop this client can satisfy (auth / auth-int, or no-qop only when
|
|
267
|
+
// allowLegacyQop). Returns a { code, msg } rejection or null. This is the SINGLE definition both answer()
|
|
268
|
+
// and parseChallenge() consult, so challenge SELECTION can never rank a challenge as usable that answer()
|
|
269
|
+
// would then refuse (or vice-versa) -- the two cannot drift.
|
|
270
|
+
function _rejection(challenge, pol, codes) {
|
|
271
|
+
var alg = ALGS[challenge.algorithm];
|
|
272
|
+
if (!alg) return { code: codes.unsupportedAlgorithm, msg: "unsupported Digest algorithm " + challenge.algorithm + " (supported: SHA-512-256, SHA-256, MD5)" };
|
|
273
|
+
if (alg.hash === "md5" && !pol.allowMD5) return { code: codes.weakAlgorithm, msg: "MD5 Digest refused by default (RFC 7616 sec. 3.4 discourages it); set opts.auth.allowMD5 for legacy interop" };
|
|
274
|
+
if (challenge.qop.length === 0) {
|
|
275
|
+
if (!pol.allowLegacyQop) return { code: codes.noQop, msg: "a no-qop (RFC 2069) Digest challenge is refused by default; set opts.auth.allowLegacyQop for legacy interop" };
|
|
276
|
+
} else if (challenge.qop.indexOf("auth") === -1 && challenge.qop.indexOf("auth-int") === -1) {
|
|
277
|
+
return { code: codes.badChallenge, msg: "the Digest qop offered no member this client supports (auth / auth-int)" };
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// answer(challenge, { method, uri, username, password, body, policy, rng }, E) -> the Authorization header value.
|
|
283
|
+
function answer(challenge, params, E) {
|
|
284
|
+
var pol = params.policy || {};
|
|
285
|
+
var codes = pol.codes || DEFAULT_CODES;
|
|
286
|
+
var rej = _rejection(challenge, pol, codes);
|
|
287
|
+
if (rej) throw E(rej.code, rej.msg);
|
|
288
|
+
var alg = ALGS[challenge.algorithm];
|
|
289
|
+
var useQop = challenge.qop.indexOf("auth") !== -1 ? "auth" : (challenge.qop.indexOf("auth-int") !== -1 ? "auth-int" : null);
|
|
290
|
+
var cnonce = String((params.rng || function () { return crypto.randomBytes(18).toString("base64"); })());
|
|
291
|
+
// The realm is used with its incoming header OCTETS unchanged (RFC 7616 sec. 4). The realm arrives from the
|
|
292
|
+
// wire, so a transport that exposes header bytes as Latin-1 (the Node default) already presents the server's
|
|
293
|
+
// UTF-8 octets one-per-char; re-encoding them as UTF-8 (as user / pass, which come from the caller as Unicode
|
|
294
|
+
// strings, correctly do) would DOUBLE-encode and make the digest disagree with the server. H() hashes over
|
|
295
|
+
// Latin-1, so the raw realm string contributes exactly the octets the server used.
|
|
296
|
+
var realm = challenge.realm, nonce = challenge.nonce;
|
|
297
|
+
// Normalize UTF-8 credentials to NFC before hashing (RFC 7616 sec. 4): a server hashes the normalized form,
|
|
298
|
+
// so a canonically-decomposed username / password (e + combining accent vs the precomposed char) must be
|
|
299
|
+
// composed first or HA1 disagrees. The SAME normalized username feeds A1, userhash, and username*.
|
|
300
|
+
var isUtf8 = String(challenge.charset || "").toUpperCase() === "UTF-8";
|
|
301
|
+
var pUser = params.username == null ? "" : String(params.username);
|
|
302
|
+
var pPass = params.password == null ? "" : String(params.password);
|
|
303
|
+
if (isUtf8) { pUser = pUser.normalize("NFC"); pPass = pPass.normalize("NFC"); }
|
|
304
|
+
var user = _octets(pUser, challenge.charset);
|
|
305
|
+
var pass = _octets(pPass, challenge.charset);
|
|
306
|
+
var HA1 = H(alg.hash, user + ":" + realm + ":" + pass);
|
|
307
|
+
if (alg.sess) HA1 = H(alg.hash, HA1 + ":" + nonce + ":" + cnonce);
|
|
308
|
+
var A2 = (useQop === "auth-int")
|
|
309
|
+
? params.method + ":" + params.uri + ":" + H(alg.hash, params.body == null ? "" : params.body)
|
|
310
|
+
: params.method + ":" + params.uri;
|
|
311
|
+
var HA2 = H(alg.hash, A2);
|
|
312
|
+
// The nonce-count: a (nonce, nc) pair MUST NOT be reused (RFC 7616 sec. 3.4), so a request that REUSES a nonce
|
|
313
|
+
// (a same-origin redirect, an auth-int retry) increments nc; a fresh nonce restarts at 1. The caller supplies
|
|
314
|
+
// the count for this nonce (default 1); formatted as 8 lowercase hex.
|
|
315
|
+
var ncNum = (typeof params.nc === "number" && params.nc >= 1) ? Math.floor(params.nc) : 1;
|
|
316
|
+
var nc = ("0000000" + ncNum.toString(16)).slice(-8);
|
|
317
|
+
var response = useQop
|
|
318
|
+
? KD(alg.hash, HA1, nonce + ":" + nc + ":" + cnonce + ":" + useQop + ":" + HA2)
|
|
319
|
+
: KD(alg.hash, HA1, nonce + ":" + HA2);
|
|
320
|
+
// The username field (RFC 7616 sec. 3.4). userhash=true sends H(username:realm) for privacy (sec. 3.4.4) --
|
|
321
|
+
// an ASCII hex value; A1 above still used the REAL username. Otherwise a charset=UTF-8 username containing
|
|
322
|
+
// non-ASCII characters MUST be carried in the extended `username*` form (RFC 5987 / RFC 8187 percent-encoded
|
|
323
|
+
// UTF-8), because a server reads the legacy quoted `username` as ISO-8859-1 and could not resolve the account;
|
|
324
|
+
// `username` and `username*` MUST NOT both appear. An ASCII (or userhash) username uses the legacy quoted form.
|
|
325
|
+
var parts;
|
|
326
|
+
if (!challenge.userhash && isUtf8 && _hasNonAscii(pUser)) {
|
|
327
|
+
parts = ["username*=UTF-8''" + _pctEncodeUtf8(pUser)];
|
|
328
|
+
} else {
|
|
329
|
+
var sentUser = challenge.userhash ? H(alg.hash, _octets(pUser, challenge.charset) + ":" + realm) : _octets(pUser, challenge.charset);
|
|
330
|
+
parts = ["username=" + _qstr(sentUser)];
|
|
331
|
+
}
|
|
332
|
+
parts.push("realm=" + _qstr(realm), "nonce=" + _qstr(nonce), "uri=" + _qstr(params.uri), "algorithm=" + challenge.algorithm);
|
|
333
|
+
if (useQop) { parts.push("qop=" + useQop); parts.push("nc=" + nc); }
|
|
334
|
+
// The cnonce directive is emitted whenever the server needs it to recompute the response: under a qop, and
|
|
335
|
+
// also under a -sess algorithm whose A1 folds in the cnonce even with no qop (RFC 7616 sec. 3.4.2 / 3.9.1).
|
|
336
|
+
if (useQop || alg.sess) { parts.push("cnonce=" + _qstr(cnonce)); }
|
|
337
|
+
parts.push("response=" + _qstr(response));
|
|
338
|
+
if (challenge.opaque != null) parts.push("opaque=" + _qstr(challenge.opaque));
|
|
339
|
+
if (challenge.userhash) parts.push("userhash=true");
|
|
340
|
+
return "Digest " + parts.join(", ");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Parse one domain URI entry (RFC 7616 sec. 3.3) into { origin, full }: an abs_path ("/a", "/a?x=1") is
|
|
344
|
+
// relative to the responding server -> origin null (any same-origin request); an ABSOLUTE URI carries its own
|
|
345
|
+
// authority -> origin "scheme://authority" (lowercased, default port normalized) and matches ONLY a request to
|
|
346
|
+
// that SAME origin, since an entry to a different host names a protection space on a different server. `full` is
|
|
347
|
+
// the pathname + query the request URI is prefix-compared against. A relative / unparseable absolute entry -> null.
|
|
348
|
+
function _domainEntry(d) {
|
|
349
|
+
d = String(d == null ? "" : d);
|
|
350
|
+
if (d.charAt(0) === "/") return { origin: null, full: d };
|
|
351
|
+
// An absolute entry is parsed through the URL constructor so its origin is NORMALIZED the same way the
|
|
352
|
+
// request's URL.origin is -- an explicit default port (https :443 / http :80) is dropped, so a domain naming
|
|
353
|
+
// "https://h:443/x" still matches a request whose origin normalizes to "https://h". A relative / unparseable
|
|
354
|
+
// entry throws and never matches (fail closed).
|
|
355
|
+
try { var u = new URL(d); return { origin: u.origin.toLowerCase(), full: (u.pathname || "/") + (u.search || "") }; }
|
|
356
|
+
catch (_e) { return null; }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// inProtectionSpace(challenge, requestOrigin, requestPathAndSearch) -> is the request within the challenge's
|
|
360
|
+
// protection space? An absent / empty domain means "all URIs on the responding server" (RFC 7616 sec. 3.3), so
|
|
361
|
+
// any same-origin request matches; otherwise membership is by LITERAL URI PREFIX (RFC 7616 sec. 3.5 / RFC 2617
|
|
362
|
+
// sec. 1.2): the request's pathname + query must have a listed domain URI as a prefix, and for an absolute entry
|
|
363
|
+
// the same origin. The query is part of the prefix, so /enroll?tenant=a does not cover /enroll?tenant=b while a
|
|
364
|
+
// query-less /enroll covers its whole subtree (including /enroll?...). Used to scope where a cached answer is reused.
|
|
365
|
+
function inProtectionSpace(challenge, requestOrigin, requestPathAndSearch) {
|
|
366
|
+
var domain = challenge && challenge.domain;
|
|
367
|
+
if (!domain || !domain.length) return true;
|
|
368
|
+
var origin = String(requestOrigin == null ? "" : requestOrigin).toLowerCase();
|
|
369
|
+
var full = String(requestPathAndSearch == null ? "" : requestPathAndSearch);
|
|
370
|
+
for (var i = 0; i < domain.length; i++) {
|
|
371
|
+
var e = _domainEntry(domain[i]);
|
|
372
|
+
if (e === null) continue;
|
|
373
|
+
if (e.origin !== null && e.origin !== origin) continue; // an absolute entry to a DIFFERENT origin is a different protection space
|
|
374
|
+
if (full.indexOf(e.full) === 0) return true;
|
|
375
|
+
}
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = { parseChallenge: parseChallenge, answer: answer, inProtectionSpace: inProtectionSpace };
|
package/lib/http-transport.js
CHANGED
|
@@ -353,9 +353,11 @@ function httpsTransport(defaults) {
|
|
|
353
353
|
timer = setTimeout(function () { fail(C("timeout"), "the request timed out after " + prep.timeout + "ms"); }, prep.timeout);
|
|
354
354
|
try {
|
|
355
355
|
req = nodeHttps.request(prep.options, function (res) {
|
|
356
|
-
// Capture the TLS session facts while the socket is live -- getProtocol() /
|
|
357
|
-
// getPeerCertificate() return null once the socket detaches at stream end.
|
|
356
|
+
// Capture the TLS session facts while the socket is live -- getProtocol() / getCipher() /
|
|
357
|
+
// getPeerCertificate() return null once the socket detaches at stream end. The negotiated cipher lets a
|
|
358
|
+
// caller refuse a NULL / anonymous / EXPORT suite before it trusts confidentiality (RFC 7030 sec. 4.4).
|
|
358
359
|
var proto = res.socket && res.socket.getProtocol ? res.socket.getProtocol() : null;
|
|
360
|
+
var cipher = res.socket && res.socket.getCipher ? res.socket.getCipher() : null;
|
|
359
361
|
var peer = res.socket && res.socket.getPeerCertificate ? res.socket.getPeerCertificate() : null;
|
|
360
362
|
// Pre-check a declared content-length so an oversized body is refused before it streams.
|
|
361
363
|
var declared = parseInt((res.headers || {})["content-length"], 10);
|
|
@@ -389,7 +391,7 @@ function httpsTransport(defaults) {
|
|
|
389
391
|
status: res.statusCode,
|
|
390
392
|
headers: lower,
|
|
391
393
|
body: Buffer.from(buf.subarray(0, len)),
|
|
392
|
-
tls: { protocol: proto, peerCertificate: peer && peer.raw ? peer.raw : null },
|
|
394
|
+
tls: { protocol: proto, cipher: cipher, peerCertificate: peer && peer.raw ? peer.raw : null },
|
|
393
395
|
});
|
|
394
396
|
});
|
|
395
397
|
res.on("error", function (e) { fail(C("transport-error"), "the response stream failed", e); });
|
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:ea40d20f-ad92-403e-8de1-cfd14bead6ba",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-
|
|
8
|
+
"timestamp": "2026-08-01T18:47:21.356Z",
|
|
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.3.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.3.28",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
25
|
+
"version": "0.3.28",
|
|
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.3.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.3.28",
|
|
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.3.
|
|
57
|
+
"ref": "@blamejs/pki@0.3.28",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|