@blamejs/pki 0.3.26 → 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 +18 -0
- package/README.md +5 -6
- package/index.js +2 -2
- package/lib/acme.js +4 -12
- package/lib/cmp-build.js +3 -1
- package/lib/cmp-session.js +1045 -0
- package/lib/cmp-verify.js +23 -5
- 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/lib/path-validate.js +3 -0
- package/lib/sleep.js +23 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/cmp-verify.js
CHANGED
|
@@ -94,7 +94,7 @@ function _verdict(m, type, protectionAlg, valid, trusted, code, reason, signer)
|
|
|
94
94
|
trusted: trusted,
|
|
95
95
|
protectionType: type,
|
|
96
96
|
protectionAlg: protectionAlg ? { oid: protectionAlg.oid, name: protectionAlg.name || null } : null,
|
|
97
|
-
signer: signer ? { cert: signer.der, spki: signer.spki, subject: signer.subject } : null,
|
|
97
|
+
signer: signer ? { cert: signer.der, spki: signer.spki, subject: signer.subject, chain: signer.chain || null } : null,
|
|
98
98
|
transactionID: m.header.transactionID || null,
|
|
99
99
|
senderNonce: m.header.senderNonce || null,
|
|
100
100
|
recipNonce: m.header.recipNonce || null,
|
|
@@ -496,6 +496,10 @@ async function _verifySignature(m, protectedPart, protectionAlg, protection, opt
|
|
|
496
496
|
// the caller to anchor. With a trust store the signer cert gets the FULL RFC 5280 sec. 6.1 path gates.
|
|
497
497
|
if (opts.trustAnchors == null) return _ok(m, "signature", protectionAlg, false, signer);
|
|
498
498
|
var trust = await _chainSigner(signer, m, opts, extra);
|
|
499
|
+
// Surface the ACTUAL validated chain (signer + the intermediates path.build used) on a trusted verdict, so a
|
|
500
|
+
// caller can cache exactly the certificates that established trust rather than the UNSIGNED extraCerts a peer
|
|
501
|
+
// can pad -- an appended-but-unused certificate never enters this chain.
|
|
502
|
+
if (trust.chain) signer.chain = trust.chain;
|
|
499
503
|
return _verdict(m, "signature", protectionAlg, true, trust.trusted, trust.trusted ? null : "cmp/untrusted-signer", trust.reason, signer);
|
|
500
504
|
}
|
|
501
505
|
|
|
@@ -545,7 +549,17 @@ async function _chainSigner(signer, m, opts, extra) {
|
|
|
545
549
|
try {
|
|
546
550
|
var res = await _engine.build(signer.der, buildOpts);
|
|
547
551
|
if (!res || res.valid !== true) return { trusted: false, reason: "the signer certificate did not chain to a supplied trust anchor" };
|
|
548
|
-
|
|
552
|
+
// res.path is the ordered certificates path.build assembled (signer + the intermediates it used), as PARSED
|
|
553
|
+
// objects whose byte fields are subarrays of the (possibly multi-MB) response allocation. Map each back to its
|
|
554
|
+
// source DER (the signer, or a DER pool entry) and return an INDEPENDENT copy, so a caller caching the chain
|
|
555
|
+
// holds standalone buffers -- never slices pinning the whole response. Only certs on the trusted path enter it,
|
|
556
|
+
// so unsigned extraCerts padding is excluded. (A path cert sourced from an already-parsed intermediate carries
|
|
557
|
+
// no DER to copy and is omitted; for a Buffer/DER pool -- the cmp.session case -- the chain is complete.)
|
|
558
|
+
var byKey = Object.create(null);
|
|
559
|
+
var skey = _certKey(signer.parsed); if (skey) byKey[skey] = signer.der;
|
|
560
|
+
pool.forEach(function (c) { if (Buffer.isBuffer(c) || c instanceof Uint8Array) { var k = _certKey(c); if (k && !byKey[k]) byKey[k] = c; } });
|
|
561
|
+
var chain = res.path.map(function (pc) { var k = _certKey(pc); var d = k ? byKey[k] : null; return d ? Buffer.from(d) : null; }).filter(Boolean);
|
|
562
|
+
return { trusted: true, reason: null, chain: chain };
|
|
549
563
|
} catch (e) {
|
|
550
564
|
// A config-tier fault from path.build (an invalid opts.time, an empty / malformed trustAnchors or
|
|
551
565
|
// intermediate) is a DEPLOYMENT error, not an untrusted signer -- rethrow it as cmp/bad-input so it is
|
|
@@ -669,9 +683,12 @@ async function _verify(message, opts) {
|
|
|
669
683
|
* Returns a verdict (never a bare boolean): `{ valid, trusted, protectionType, protectionAlg, signer,
|
|
670
684
|
* transactionID, senderNonce, recipNonce, header, body, code?, reason? }`. `valid` is whether the
|
|
671
685
|
* protection is cryptographically intact under the declared algorithm; `trusted` is whether a MAC secret
|
|
672
|
-
* matched or a signature signer certificate chained to a supplied trust anchor.
|
|
673
|
-
*
|
|
674
|
-
*
|
|
686
|
+
* matched or a signature signer certificate chained to a supplied trust anchor. On a trusted signature
|
|
687
|
+
* verdict `signer.chain` is the validated certificate path as independent DER buffers (the signer plus the
|
|
688
|
+
* intermediates that chained it to the anchor) -- the certificates actually used, never the unsigned
|
|
689
|
+
* `extraCerts` a peer can pad, and never a slice pinning the response allocation. A
|
|
690
|
+
* well-formed but unverifiable message is a `{ valid: false }` verdict carrying a `cmp/*` code, not a throw;
|
|
691
|
+
* only malformed input (a non-PKIMessage, a bad required opt, a flavor/credential mismatch) throws a typed `CmpError`.
|
|
675
692
|
*
|
|
676
693
|
* @opts
|
|
677
694
|
* - `sharedSecret` (string|Buffer) -- the PBMAC1 secret; REQUIRED for a MAC-protected message (UTF-8).
|
|
@@ -702,4 +719,5 @@ module.exports = {
|
|
|
702
719
|
wellKnownUrl: cmpBuild.wellKnownUrl,
|
|
703
720
|
verify: verify,
|
|
704
721
|
setEngine: setEngine,
|
|
722
|
+
senderBoundToCert: _senderBoundToCert,
|
|
705
723
|
};
|
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,
|