@blamejs/pki 0.4.14 → 0.5.0
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 +61 -0
- package/MIGRATING.md +2 -2
- package/README.md +142 -137
- package/index.js +4 -0
- package/lib/acme.js +73 -1
- package/lib/asn1-der.js +2 -0
- package/lib/attrcert-sign.js +4 -0
- package/lib/cbor-det.js +32 -16
- package/lib/cmc-build.js +880 -0
- package/lib/cmc-verify.js +657 -0
- package/lib/cmp-build.js +8 -7
- package/lib/cmp-verify.js +11 -1
- package/lib/cms-sign.js +170 -8
- package/lib/cms-verify.js +80 -14
- package/lib/crl-sign.js +22 -0
- package/lib/crmf-sign.js +5 -2
- package/lib/csr-sign.js +3 -0
- package/lib/ct.js +72 -0
- package/lib/est.js +828 -32
- package/lib/framework-error.js +13 -0
- package/lib/guard-bytes.js +37 -1
- package/lib/guard-range.js +23 -1
- package/lib/http-transport.js +9 -3
- package/lib/inspect.js +28 -5
- package/lib/jose.js +15 -0
- package/lib/lint.js +4 -0
- package/lib/merkle.js +5 -5
- package/lib/ocsp.js +139 -11
- package/lib/oid.js +69 -1
- package/lib/path-validate.js +438 -100
- package/lib/pkcs12-build.js +12 -0
- package/lib/schema-all.js +19 -1
- package/lib/schema-attrcert.js +27 -0
- package/lib/schema-c509.js +6 -0
- package/lib/schema-cmc.js +791 -0
- package/lib/schema-cmp.js +25 -0
- package/lib/schema-cms.js +17 -1
- package/lib/schema-crl.js +23 -1
- package/lib/schema-crmf.js +13 -0
- package/lib/schema-csr.js +11 -0
- package/lib/schema-csrattrs.js +6 -0
- package/lib/schema-engine.js +6 -2
- package/lib/schema-ocsp.js +41 -0
- package/lib/schema-pkcs12.js +16 -0
- package/lib/schema-pkcs8.js +8 -0
- package/lib/schema-pkix.js +6 -0
- package/lib/schema-smime.js +4 -4
- package/lib/schema-tsp.js +32 -1
- package/lib/schema-x509.js +14 -1
- package/lib/shbs.js +12 -4
- package/lib/sigstore.js +4 -0
- package/lib/smime.js +28 -7
- package/lib/tls-cert-compress.js +15 -3
- package/lib/trust.js +27 -4
- package/lib/tsp-sign.js +41 -6
- package/lib/vendor/README.md +19 -19
- package/lib/webauthn.js +895 -26
- package/lib/x509-sign.js +3 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/est.js
CHANGED
|
@@ -40,9 +40,12 @@
|
|
|
40
40
|
* certificates are in any order", RFC 5272 sec. 4.1) -- `findIssuedCert` picks
|
|
41
41
|
* the issued certificate by a public-key match, never a positional guess. The
|
|
42
42
|
* serverkeygen encrypted-key part's EnvelopedData is surfaced structurally
|
|
43
|
-
* (ciphertext raw, decryption external). /fullcmc is
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* (ciphertext raw, decryption external). A /fullcmc response is CLASSIFIED:
|
|
44
|
+
* a 200 may carry either arm RFC 7030 sec. 4.3.2 permits (`certs-only` or
|
|
45
|
+
* `CMC-response`), and a 404 or 501 is the distinct `not-implemented`
|
|
46
|
+
* verdict -- this service absent, rather than a transport fault. Reading the
|
|
47
|
+
* CMC message itself is the CMC module's job. DER-only where DER,
|
|
48
|
+
* fail-closed everywhere.
|
|
46
49
|
*
|
|
47
50
|
* @card
|
|
48
51
|
* EST (RFC 7030 / 8951 / 9908) client -- the cacerts / simpleenroll / simplereenroll
|
|
@@ -60,6 +63,12 @@ var pkcs8 = require("./schema-pkcs8");
|
|
|
60
63
|
var key = require("./key");
|
|
61
64
|
var csr = require("./schema-csr");
|
|
62
65
|
var csrattrsFmt = require("./schema-csrattrs"); // aliased: the `csrattrs` name is the verb + the public export
|
|
66
|
+
var cmcVerify = require("./cmc-verify"); // /fullcmc interprets its response through the CMC layer
|
|
67
|
+
var cmcFmt = require("./schema-cmc"); // to read back which keys the submitted request asked to certify
|
|
68
|
+
var OID_CMC_TRANSACTION_ID = oid.byName("id-cmc-transactionId");
|
|
69
|
+
var OID_CMC_SENDER_NONCE = oid.byName("id-cmc-senderNonce");
|
|
70
|
+
var OID_CMC_DATA_RETURN = oid.byName("id-cmc-dataReturn");
|
|
71
|
+
var crmfFmt = require("./schema-crmf");
|
|
63
72
|
var frameworkError = require("./framework-error");
|
|
64
73
|
var guard = require("./guard-all");
|
|
65
74
|
var httpTransport = require("./http-transport");
|
|
@@ -94,6 +103,7 @@ var OPERATIONS = ["cacerts", "simpleenroll", "simplereenroll", "fullcmc", "serve
|
|
|
94
103
|
* decoded DER against `DER_MAX_BYTES` (`est/too-large`).
|
|
95
104
|
*
|
|
96
105
|
* @example
|
|
106
|
+
* var der = pki.asn1.build.sequence([pki.asn1.build.integer(1n)]);
|
|
97
107
|
* var roundTripped = pki.est.transferDecode(pki.est.transferEncode(der));
|
|
98
108
|
*/
|
|
99
109
|
function transferDecode(body) {
|
|
@@ -128,6 +138,7 @@ function transferDecode(body) {
|
|
|
128
138
|
* (senders need not insert whitespace, RFC 8951 sec. 3.1).
|
|
129
139
|
*
|
|
130
140
|
* @example
|
|
141
|
+
* var der = pki.asn1.build.sequence([pki.asn1.build.integer(1n)]);
|
|
131
142
|
* var body = pki.est.transferEncode(der);
|
|
132
143
|
*/
|
|
133
144
|
function transferEncode(der) {
|
|
@@ -144,8 +155,14 @@ function transferEncode(der) {
|
|
|
144
155
|
function _multipartBoundary(contentType) {
|
|
145
156
|
var ct = String(contentType || "");
|
|
146
157
|
if (!/^multipart\/mixed\s*(;|$)/i.test(ct)) return null;
|
|
147
|
-
|
|
148
|
-
|
|
158
|
+
// Two boundaries name two different splits of the same body; there is no reading
|
|
159
|
+
// of that header, so it is refused rather than resolved by taking the first.
|
|
160
|
+
var bp = _ctParam(ct, "boundary");
|
|
161
|
+
if (bp.duplicated) {
|
|
162
|
+
throw E("est/bad-multipart",
|
|
163
|
+
"the multipart Content-Type declares more than one boundary, so where the parts begin is ambiguous (RFC 2045 sec. 5.1)");
|
|
164
|
+
}
|
|
165
|
+
return bp.value;
|
|
149
166
|
}
|
|
150
167
|
|
|
151
168
|
// Split a multipart/mixed body into its parts, each { headers, contentType,
|
|
@@ -211,6 +228,16 @@ function splitMultipartMixed(body, contentType) {
|
|
|
211
228
|
* `EstError` (`est/not-certs-only`, `est/no-certificates`).
|
|
212
229
|
*
|
|
213
230
|
* @example
|
|
231
|
+
* var b = pki.asn1.build;
|
|
232
|
+
* var pair = await pki.key.generate("Ed25519");
|
|
233
|
+
* var certDer = await pki.x509.sign({ subject: "Example CA", subjectPublicKey: await pki.key.export(pair.publicKey),
|
|
234
|
+
* notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") },
|
|
235
|
+
* { key: await pki.key.export(pair.privateKey) });
|
|
236
|
+
* // the certs-only Simple PKI Response shape (RFC 7030 sec. 4.1.3): a SignedData
|
|
237
|
+
* // v1 over id-data with no eContent, the certificates, and an EMPTY signerInfos
|
|
238
|
+
* var caCertsDer = b.sequence([b.oid("1.2.840.113549.1.7.2"), b.explicit(0, b.sequence([
|
|
239
|
+
* b.integer(1n), b.set([]), b.sequence([b.oid("1.2.840.113549.1.7.1")]),
|
|
240
|
+
* b.contextConstructed(0, certDer), b.set([])]))]);
|
|
214
241
|
* var r = pki.est.parseCertsOnly(caCertsDer);
|
|
215
242
|
* r.certificates; // -> [Buffer, ...] raw, unordered
|
|
216
243
|
*/
|
|
@@ -333,20 +360,39 @@ function _splitContentTypeParams(s) {
|
|
|
333
360
|
// like application/pkcs8evil yields that literal, not application/pkcs8), and
|
|
334
361
|
// parameters are read token-by-token honoring quoted-strings so a smime-type-like
|
|
335
362
|
// substring inside another quoted parameter value is NOT taken as smime-type.
|
|
336
|
-
|
|
363
|
+
// A named Content-Type parameter, plus whether the header declared it MORE THAN
|
|
364
|
+
// ONCE. RFC 2045 sec. 5.1 gives a parameter at most one value, so a header stating
|
|
365
|
+
// two is ambiguous -- and taking the first would let the position of a duplicate,
|
|
366
|
+
// not the sender, decide how the body is read. `smime-type` selects which response
|
|
367
|
+
// arm this is and `boundary` decides where the parts begin, so in both cases the
|
|
368
|
+
// choice is the whole answer. Reported rather than thrown here: each boundary
|
|
369
|
+
// refuses in its own terms, and the error path must not let a bad label displace
|
|
370
|
+
// the HTTP fault it arrived with.
|
|
371
|
+
function _ctParam(contentType, name) {
|
|
337
372
|
var segs = _splitContentTypeParams(String(contentType || ""));
|
|
338
|
-
var
|
|
339
|
-
var smimeType = null;
|
|
373
|
+
var value = null, count = 0;
|
|
340
374
|
for (var i = 1; i < segs.length; i++) {
|
|
341
375
|
var eq = segs[i].indexOf("=");
|
|
342
376
|
if (eq === -1) continue;
|
|
343
|
-
if (segs[i].slice(0, eq).trim().toLowerCase() !==
|
|
377
|
+
if (segs[i].slice(0, eq).trim().toLowerCase() !== name) continue;
|
|
378
|
+
count++;
|
|
379
|
+
if (count > 1) continue;
|
|
344
380
|
var val = segs[i].slice(eq + 1).trim();
|
|
345
381
|
if (val.length >= 2 && val.charAt(0) === '"' && val.charAt(val.length - 1) === '"') val = val.slice(1, -1);
|
|
346
|
-
|
|
347
|
-
break;
|
|
382
|
+
value = val;
|
|
348
383
|
}
|
|
349
|
-
return {
|
|
384
|
+
return { value: value, duplicated: count > 1 };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function _partMediaType(contentType) {
|
|
388
|
+
var segs = _splitContentTypeParams(String(contentType || ""));
|
|
389
|
+
var mediaMatch = /^\s*([a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*)\s*$/i.exec(segs[0]);
|
|
390
|
+
var st = _ctParam(contentType, "smime-type");
|
|
391
|
+
return {
|
|
392
|
+
media: mediaMatch ? mediaMatch[1].toLowerCase() : null,
|
|
393
|
+
smimeType: st.value === null ? null : st.value.toLowerCase(),
|
|
394
|
+
ambiguous: st.duplicated,
|
|
395
|
+
};
|
|
350
396
|
}
|
|
351
397
|
|
|
352
398
|
function parseServerKeygenResponse(body, contentType, opts) {
|
|
@@ -356,6 +402,13 @@ function parseServerKeygenResponse(body, contentType, opts) {
|
|
|
356
402
|
var keyPart = null, certPart = null, encrypted = false;
|
|
357
403
|
for (var i = 0; i < parts.length; i++) {
|
|
358
404
|
var pt = _partMediaType(parts[i].contentType);
|
|
405
|
+
// Same rule on a PART header: smime-type tells the encrypted key part from the
|
|
406
|
+
// cleartext one and from the certificate part, so two of them leave which part
|
|
407
|
+
// this is undecided.
|
|
408
|
+
if (pt.ambiguous) {
|
|
409
|
+
throw E("est/bad-multipart",
|
|
410
|
+
"a serverkeygen response part declares more than one smime-type, so which part it is cannot be told from it (RFC 2045 sec. 5.1)");
|
|
411
|
+
}
|
|
359
412
|
if (pt.media === "application/pkcs8") { keyPart = parts[i]; encrypted = false; }
|
|
360
413
|
else if (pt.media === "application/pkcs7-mime" && pt.smimeType === "server-generated-key") { keyPart = parts[i]; encrypted = true; }
|
|
361
414
|
// The certificate part exactly matches the /simpleenroll response
|
|
@@ -412,19 +465,37 @@ function parseServerKeygenResponse(body, contentType, opts) {
|
|
|
412
465
|
// PKI message type (CMC-response, ...) is not accepted; cacerts mandates only the
|
|
413
466
|
// media type (sec. 4.1.3). Matched token-wise, not by prefix, so a look-alike like
|
|
414
467
|
// "application/pkcs7-mimeevil" is rejected.
|
|
468
|
+
// smimeTypes is a LIST for every row that constrains it, rather than a scalar on some
|
|
469
|
+
// rows and a list on others: /fullcmc legitimately answers with either arm, and one
|
|
470
|
+
// shape for the field keeps the comparison below single-branch.
|
|
415
471
|
var CONTENT_TYPE_BY_OP = {
|
|
416
472
|
cacerts: { media: "application/pkcs7-mime" },
|
|
417
|
-
simpleenroll: { media: "application/pkcs7-mime",
|
|
418
|
-
simplereenroll: { media: "application/pkcs7-mime",
|
|
473
|
+
simpleenroll: { media: "application/pkcs7-mime", smimeTypes: ["certs-only"] },
|
|
474
|
+
simplereenroll: { media: "application/pkcs7-mime", smimeTypes: ["certs-only"] },
|
|
475
|
+
// RFC 7030 sec. 4.3.2: a 200 /fullcmc response is application/pkcs7-mime whose
|
|
476
|
+
// smime-type is EITHER certs-only (the CA answered with a Simple PKI Response) or
|
|
477
|
+
// CMC-response (a Full PKI Response). Both are conforming and the CA chooses.
|
|
478
|
+
// Spelled lowercase here and compared case-insensitively: RFC 5273 sec. 3 prose
|
|
479
|
+
// writes "CMC-Request"/"CMC-Response" while its own Table 1 and RFC 7030 write
|
|
480
|
+
// "CMC-request"/"CMC-response", so a case-sensitive match would reject a
|
|
481
|
+
// conforming server depending on which sentence its author read.
|
|
482
|
+
fullcmc: { media: "application/pkcs7-mime", smimeTypes: ["certs-only", "cmc-response"] },
|
|
419
483
|
serverkeygen: { media: "multipart/mixed" },
|
|
420
484
|
csrattrs: { media: "application/csrattrs" },
|
|
421
485
|
};
|
|
422
486
|
|
|
423
|
-
// The operations whose responses this client classifies.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
//
|
|
427
|
-
|
|
487
|
+
// The operations whose responses this client classifies.
|
|
488
|
+
var CLASSIFIABLE_OPS = ["cacerts", "simpleenroll", "simplereenroll", "fullcmc", "serverkeygen", "csrattrs"];
|
|
489
|
+
|
|
490
|
+
// RFC 7030 sec. 4.3.2: on /fullcmc a 404 OR a 501 means "this service is not
|
|
491
|
+
// implemented" -- a distinct, non-error verdict the caller can act on.
|
|
492
|
+
//
|
|
493
|
+
// That makes THREE meanings for 404 across this classifier, so they are listed
|
|
494
|
+
// together rather than as branches accumulated one release at a time:
|
|
495
|
+
// /csrattrs 404 -> none-available (sec. 4.5.2)
|
|
496
|
+
// /fullcmc 404 -> not-implemented (sec. 4.3.2), and 501 likewise
|
|
497
|
+
// every other op -> est/http-error
|
|
498
|
+
var NOT_IMPLEMENTED_OPS = { fullcmc: 1 };
|
|
428
499
|
|
|
429
500
|
/**
|
|
430
501
|
* @primitive pki.est.classifyResponse
|
|
@@ -455,20 +526,40 @@ function classifyResponse(status, headers, body, opts) {
|
|
|
455
526
|
opts = opts || {};
|
|
456
527
|
var op = opts.op;
|
|
457
528
|
// Fail closed on an operation whose response this client cannot validate:
|
|
458
|
-
//
|
|
529
|
+
// A named op this client cannot validate is a typo, not a pass. An
|
|
459
530
|
// absent op is the caller opting out of the content-type gate (generic
|
|
460
531
|
// status handling) and stays permissive.
|
|
461
|
-
if (op === "fullcmc") throw E("est/fullcmc-not-supported", "the /fullcmc operation is recognized but its CMC response is not supported by this client (RFC 7030 sec. 4.3)");
|
|
462
532
|
if (op !== undefined && op !== null && CLASSIFIABLE_OPS.indexOf(op) === -1) throw E("est/unsupported-operation", "unrecognized EST operation " + JSON.stringify(op));
|
|
463
|
-
|
|
464
|
-
|
|
533
|
+
// Normalized ONCE, through the reader that refuses a field carried under two
|
|
534
|
+
// spellings. Folding the map with last-value-wins here while another stage reads
|
|
535
|
+
// the exact key is how the two could pick different values out of the same
|
|
536
|
+
// response -- and the whole point of this function is to decide what the body is.
|
|
537
|
+
// Content-Type, Retry-After and Location are SINGLETON fields: each carries one
|
|
538
|
+
// value that decides one thing -- how to read the body, how long to wait, where to
|
|
539
|
+
// go -- so two of them is an ambiguity, refused rather than resolved by order.
|
|
540
|
+
// WWW-Authenticate is a LIST field (RFC 9110 sec. 11.6.1: a challenge per element),
|
|
541
|
+
// where repetition is the field working as defined; its values are combined the way
|
|
542
|
+
// sec. 5.3 permits a recipient to combine list-field lines, so every challenge the
|
|
543
|
+
// server offered reaches the scheme scan.
|
|
544
|
+
var h = {
|
|
545
|
+
"content-type": _ciHeader(headers, "content-type"),
|
|
546
|
+
"retry-after": _ciHeader(headers, "retry-after"),
|
|
547
|
+
"location": _ciHeader(headers, "location"),
|
|
548
|
+
"www-authenticate": _ciHeaderList(headers, "www-authenticate"),
|
|
549
|
+
};
|
|
465
550
|
if (status === 200) {
|
|
466
551
|
var spec = CONTENT_TYPE_BY_OP[op];
|
|
467
552
|
var ct = h["content-type"] || "";
|
|
468
553
|
if (spec) {
|
|
469
554
|
var pt = _partMediaType(ct);
|
|
470
|
-
|
|
471
|
-
|
|
555
|
+
// _partMediaType already lowercases smimeType, and the table's entries are
|
|
556
|
+
// lowercase, so this comparison is case-insensitive on both sides.
|
|
557
|
+
// A duplicated smime-type is not a value the whitelist can be applied to:
|
|
558
|
+
// one of the two might be on it while the other is not, and matching the
|
|
559
|
+
// first would pass a header that also declares something else.
|
|
560
|
+
var smimeOk = !pt.ambiguous && (!spec.smimeTypes || spec.smimeTypes.indexOf(pt.smimeType) !== -1);
|
|
561
|
+
if (pt.media !== spec.media || !smimeOk) {
|
|
562
|
+
throw E("est/bad-content-type", "a 200 " + op + " response must carry content-type " + spec.media + (spec.smimeTypes ? "; smime-type=" + spec.smimeTypes.join(" or ") : "") + ", got " + JSON.stringify(ct));
|
|
472
563
|
}
|
|
473
564
|
}
|
|
474
565
|
return { status: "ok", contentType: ct };
|
|
@@ -483,8 +574,12 @@ function classifyResponse(status, headers, body, opts) {
|
|
|
483
574
|
var parsed = retryAfter.parse(raStr, { now: opts.now, E: E, code: "est/bad-retry-after" });
|
|
484
575
|
return { status: "retry", retryAfter: raStr, retryAfterSeconds: parsed.retryAfterSeconds, retryAfterDate: parsed.retryAfterDate };
|
|
485
576
|
}
|
|
577
|
+
// 501 is meaningful ONLY as the /fullcmc not-implemented signal; on every other
|
|
578
|
+
// operation it stays an ordinary 5xx and falls through to est/http-error below.
|
|
579
|
+
if (status === 501 && NOT_IMPLEMENTED_OPS[op]) return { status: "not-implemented", httpStatus: status };
|
|
486
580
|
if (status === 204 || status === 404) {
|
|
487
581
|
if (op === "csrattrs") return { status: "none-available" };
|
|
582
|
+
if (status === 404 && NOT_IMPLEMENTED_OPS[op]) return { status: "not-implemented", httpStatus: status };
|
|
488
583
|
throw E("est/http-error", "HTTP " + status + " is not a valid " + op + " response");
|
|
489
584
|
}
|
|
490
585
|
if (status >= 300 && status < 400) return { status: "redirect", location: h["location"] || null };
|
|
@@ -808,8 +903,17 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
|
808
903
|
// The transport contract returns lowercased headers, but the injectable seam only promises
|
|
809
904
|
// { status, headers, body }; normalize here so a redirect Location / WWW-Authenticate from an
|
|
810
905
|
// injected transport using ordinary HTTP casing is read correctly (never missed as absent).
|
|
811
|
-
|
|
812
|
-
|
|
906
|
+
//
|
|
907
|
+
// Through the SAME accessors classifyResponse uses, not a fold of its own: a
|
|
908
|
+
// last-value-wins fold keeps one line of a field that arrived under two
|
|
909
|
+
// spellings, so a usable challenge sent before an unusable one would vanish
|
|
910
|
+
// and the exchange would be refused for offering nothing this client speaks.
|
|
911
|
+
// Location is a singleton, so a second one is an ambiguity and refused;
|
|
912
|
+
// WWW-Authenticate is a list, so every challenge is kept.
|
|
913
|
+
var h = {
|
|
914
|
+
location: _ciHeader(res.headers, "location"),
|
|
915
|
+
"www-authenticate": _ciHeaderList(res.headers, "www-authenticate"),
|
|
916
|
+
};
|
|
813
917
|
var status = res.status;
|
|
814
918
|
if (status >= 300 && status < 400) {
|
|
815
919
|
if (redirects >= budgets.maxRedirects) throw E("est/too-many-redirects", "the redirect chain exceeded maxRedirects=" + budgets.maxRedirects + " (RFC 7030 sec. 3.2.1)");
|
|
@@ -960,6 +1064,662 @@ function _certsResult(op, res, opts, csrSpki) {
|
|
|
960
1064
|
return { certificate: issued, chain: chain, certificates: parsed.certificates };
|
|
961
1065
|
}
|
|
962
1066
|
|
|
1067
|
+
// ---- /fullcmc (RFC 7030 sec. 4.3) ---------------------------------------
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* @primitive pki.est.fullcmc
|
|
1071
|
+
* @signature pki.est.fullcmc(baseUrl, request, opts?) -> Promise<verdict | { retry, retryAfterSeconds }>
|
|
1072
|
+
* @since 0.4.16
|
|
1073
|
+
* @status experimental
|
|
1074
|
+
* @spec RFC 7030, RFC 8951, RFC 5273, RFC 5272
|
|
1075
|
+
* @related pki.cmc.build, pki.cmc.verify, pki.est.simpleenroll
|
|
1076
|
+
*
|
|
1077
|
+
* Enroll through the full CMC message layer: POST a Full PKI Request (from `pki.cmc.build`, as a
|
|
1078
|
+
* DER Buffer or a PEM `CMS` block) to `<baseUrl>/.well-known/est/fullcmc` as
|
|
1079
|
+
* `application/pkcs7-mime; smime-type=CMC-request`, base64 per RFC 8951, over the shared
|
|
1080
|
+
* `pki.transport`.
|
|
1081
|
+
*
|
|
1082
|
+
* A 200 answers with EITHER `smime-type=certs-only` (a Simple PKI Response) or
|
|
1083
|
+
* `smime-type=CMC-response` (a Full PKI Response) -- RFC 7030 sec. 4.3.2 names both, and the label
|
|
1084
|
+
* must agree with the bytes. Either way the result is the `pki.cmc.verify` verdict shape, so a
|
|
1085
|
+
* caller reads one `outcome` (`issued` / `pending` / `confirm-required` / `pop-required` /
|
|
1086
|
+
* `rejected`) regardless of which arm the server chose. Pass `transactionId` / `senderNonce` /
|
|
1087
|
+
* `dataReturn` to have the exchange bound to the request that was sent.
|
|
1088
|
+
*
|
|
1089
|
+
* A 404 **or** a 501 is the distinct `est/not-implemented` verdict -- support for this verb is
|
|
1090
|
+
* OPTIONAL on both sides (sec. 4.3). A 202 surfaces its Retry-After rather than sleeping. A
|
|
1091
|
+
* rejection carries a CMC response (sec. 4.3.2 makes it a MUST), which is decoded and attached to
|
|
1092
|
+
* a typed `est/cmc-failed` as `err.cmc` and `err.httpStatus` -- but a body that cannot be read
|
|
1093
|
+
* never masks the HTTP fault it arrived with.
|
|
1094
|
+
*
|
|
1095
|
+
* On the `certs-only` arm the issued certificates are identified by PUBLIC-KEY MATCH against the
|
|
1096
|
+
* requests that were submitted -- the only identification RFC 5272 sec. 4.1 sanctions, since "the
|
|
1097
|
+
* certificates are in any order" -- and EVERY certification request in the message must be answered
|
|
1098
|
+
* before the exchange reads as `issued` -- a key wanted by N requests needs N certificates, so a
|
|
1099
|
+
* bag that answers only some of them, or none, is a refusal rather than a partial success. That arm
|
|
1100
|
+
* carries no controls, so it cannot echo a Transaction Identifier, Sender Nonce or Data Return: a
|
|
1101
|
+
* request that sent those asked for replay binding it cannot provide -- the key match is not one,
|
|
1102
|
+
* since an old response for the same key still matches -- and it is refused as
|
|
1103
|
+
* `est/unbound-response` rather than accepted with silently none of what was asked for. They are
|
|
1104
|
+
* surfaced as `issuedCertificates` (with `certificate` the first), distinct from `certificates`,
|
|
1105
|
+
* which is the whole returned bag including any chain. Where the requested keys are distinct that
|
|
1106
|
+
* list is in request order; where several requests deliberately SHARE one key it is not, and does
|
|
1107
|
+
* not claim to be -- the public key is the only identification sec. 4.1 sanctions, so when it is
|
|
1108
|
+
* shared nothing in the response says which of those requests a given certificate answers. That arm reports `signatureVerified: false`: a certs-only body is
|
|
1109
|
+
* a degenerate SignedData with no signers by definition, so its security rests on the authenticated
|
|
1110
|
+
* TLS channel, not on a signature.
|
|
1111
|
+
*
|
|
1112
|
+
* Every EST transport gate holds unchanged, including on a bootstrap enrollment: https-only, an
|
|
1113
|
+
* explicit trust anchor required, redirect and size bounds. A Publish Trust Anchors control in the
|
|
1114
|
+
* response is SURFACED, never acted on (RFC 5272 sec. 6.15 makes accepting one a manual decision).
|
|
1115
|
+
*
|
|
1116
|
+
* @opts
|
|
1117
|
+
* - `transport` / `tls` / `label` / `timeout` / `maxResponseBytes` / `maxRedirects` / `now` -- as pki.est.cacerts.
|
|
1118
|
+
* - `transactionId` / `senderNonce` / `dataReturn` -- what the request sent, for the exchange binding.
|
|
1119
|
+
* - `responderCerts` -- EXTRA certificates for CMC signer lookup, for a response that does not carry
|
|
1120
|
+
* its own signer; the certificates the response carries are searched either way. The carrier's
|
|
1121
|
+
* signature MUST be verified (RFC 5272 sec. 3.2.1.3.4), so a `CMC-response` whose signer is found
|
|
1122
|
+
* nowhere and which does not name the opt-out below is refused.
|
|
1123
|
+
* - `responseRecipient` -- key material for a response carried in AuthenticatedData, in the shape
|
|
1124
|
+
* `pki.cms.decrypt` takes. Its MAC is then checked and the verdict reports
|
|
1125
|
+
* `signatureVerified: true`, rather than the carrier being reachable only unauthenticated.
|
|
1126
|
+
* - `allowUnverifiedResponse` -- accept a `CMC-response` whose signer certificate cannot be found,
|
|
1127
|
+
* without checking its signature; the verdict then reports `signatureVerified: false`. For an
|
|
1128
|
+
* unauthenticated bootstrap only, and it never excuses a signature that is present and wrong.
|
|
1129
|
+
* - `username` / `password` / `allowCrossOriginRedirect` -- as pki.est.simpleenroll.
|
|
1130
|
+
* @example
|
|
1131
|
+
* var pair = await pki.key.generate("Ed25519");
|
|
1132
|
+
* var key = await pki.key.export(pair.privateKey);
|
|
1133
|
+
* var spki = await pki.key.export(pair.publicKey);
|
|
1134
|
+
* var cert = await pki.x509.sign({ subject: "device.example", subjectPublicKey: spki,
|
|
1135
|
+
* notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") }, { key: key });
|
|
1136
|
+
* var csr = await pki.csr.sign({ subject: "device.example", subjectPublicKey: spki }, { key: key });
|
|
1137
|
+
* var request = await pki.cmc.build({ requests: [{ tcr: csr }] }, { cert: cert, key: key });
|
|
1138
|
+
* // a 202 means the CA queued the request -- the verb surfaces the delay, never sleeps
|
|
1139
|
+
* var r = await pki.est.fullcmc("https://ca.example", request,
|
|
1140
|
+
* { transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
|
|
1141
|
+
* r.retry && r.retryAfterSeconds; // 60
|
|
1142
|
+
*/
|
|
1143
|
+
function fullcmc(baseUrl, request, opts) {
|
|
1144
|
+
opts = opts || {};
|
|
1145
|
+
// Everything this exchange MEANS is captured synchronously, before a single
|
|
1146
|
+
// deferred turn: the request bytes (copied), the keys they ask to have
|
|
1147
|
+
// certified, and the state the response will be checked against. Doing it
|
|
1148
|
+
// inside the promise body below would be a turn too late -- a caller that
|
|
1149
|
+
// mutates the buffer or flips allowUnverifiedResponse on the line after this
|
|
1150
|
+
// call has already changed them by then, which is the easiest version of the
|
|
1151
|
+
// race to hit and the one that would skip the signature check.
|
|
1152
|
+
var der, wanted, sent;
|
|
1153
|
+
try {
|
|
1154
|
+
if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw E("est/bad-input", "pki.est.fullcmc options must be an object");
|
|
1155
|
+
der = _cmcRequestDer(request);
|
|
1156
|
+
// Confirm this IS a Full PKI Request before any of it goes over the wire. The
|
|
1157
|
+
// bytes are about to be labelled `smime-type=CMC-request`, so a PKIResponse or
|
|
1158
|
+
// an unparseable blob handed in by mistake would be POSTed to a CA under a
|
|
1159
|
+
// label that does not describe it -- and, since only an `issued` outcome is
|
|
1160
|
+
// correlated, could still come back as a pending or rejected verdict, making
|
|
1161
|
+
// the mistake look like a protocol answer. A caller error belongs at the entry
|
|
1162
|
+
// point, not on the network.
|
|
1163
|
+
wanted = _requestedPublicKeys(der);
|
|
1164
|
+
// The exchange state is READ BACK OUT OF THE REQUEST, not taken on the caller's
|
|
1165
|
+
// word. A value supplied here that the request does not actually carry would
|
|
1166
|
+
// have the response checked against a binding this exchange never sent -- and a
|
|
1167
|
+
// replayed response echoing that value would satisfy it. What the request says
|
|
1168
|
+
// is the only thing that can bind the answer to it.
|
|
1169
|
+
sent = _cmcSent(opts, der);
|
|
1170
|
+
// The TRANSPORT options are pinned here too, not only the binding state. They
|
|
1171
|
+
// decide where this request goes and what trust it goes under -- transport,
|
|
1172
|
+
// tls, credentials, redirect policy.
|
|
1173
|
+
opts = _shallowCopy(opts);
|
|
1174
|
+
// And the request is STARTED here, synchronously, rather than in a deferred
|
|
1175
|
+
// turn. That is what actually closes the window: a copy only reaches as deep
|
|
1176
|
+
// as it is written, so nested config (opts.tls and its anchors) would still be
|
|
1177
|
+
// the caller's to change while the request sat queued. Handing the options to
|
|
1178
|
+
// _client before yielding leaves no turn in which any of them, at any depth,
|
|
1179
|
+
// can differ from what this call was made with.
|
|
1180
|
+
var body = transferEncode(der);
|
|
1181
|
+
return _client("fullcmc", "POST", baseUrl, body,
|
|
1182
|
+
{ accept: "application/pkcs7-mime", "content-type": "application/pkcs7-mime; smime-type=CMC-request" }, opts)
|
|
1183
|
+
.then(function (res) { return _fullcmcResult(res, opts, wanted, sent); });
|
|
1184
|
+
} catch (e) {
|
|
1185
|
+
return Promise.reject(e); // the surface stays promise-rejecting, never throwing
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Tie a returned certificate bag back to the requests it claims to answer, for
|
|
1191
|
+
* BOTH response arms -- the rule is one rule, so it lives in one place rather than
|
|
1192
|
+
* being restated per arm where one arm can quietly lose it.
|
|
1193
|
+
*
|
|
1194
|
+
* Every certification request the message submitted must be answered before the
|
|
1195
|
+
* exchange reads as an issuance: a bag holding a CA chain, someone else's
|
|
1196
|
+
* certificate, or only some of the requested keys is a partial or unrelated answer,
|
|
1197
|
+
* not a success. Matching is by public key, the only identification RFC 5272
|
|
1198
|
+
* sec. 4.1 sanctions ("the certificates are in any order" -- position means
|
|
1199
|
+
* nothing).
|
|
1200
|
+
*
|
|
1201
|
+
* Each certificate is CONSUMED as it is matched, so two requests that share a
|
|
1202
|
+
* public key -- CSRs for different subjects, say -- need two certificates rather
|
|
1203
|
+
* than matching the same one twice and reporting an unanswered request as answered.
|
|
1204
|
+
*/
|
|
1205
|
+
function _correlateIssued(bag, wanted, arm) {
|
|
1206
|
+
if (!wanted.length) {
|
|
1207
|
+
throw E("est/no-issued-cert",
|
|
1208
|
+
"this /fullcmc request declared no certification request whose key a returned certificate could be " +
|
|
1209
|
+
"matched against, so a " + arm + " response cannot be read as an issuance (RFC 5272 sec. 4.1)");
|
|
1210
|
+
}
|
|
1211
|
+
// Counted PER DISTINCT KEY, because "how many certificates should match this
|
|
1212
|
+
// key?" is answered by how many requests asked for it. Two requests may share a
|
|
1213
|
+
// public key deliberately -- different subjects for one key -- and a CA
|
|
1214
|
+
// answering both returns two certificates for it. Rejecting the second as
|
|
1215
|
+
// ambiguous would refuse the complete, correct response; requiring only one
|
|
1216
|
+
// would let a half-answer pass. So the count must agree exactly.
|
|
1217
|
+
var byKey = {};
|
|
1218
|
+
wanted.forEach(function (k) {
|
|
1219
|
+
var hex = k.toString("hex");
|
|
1220
|
+
if (!byKey[hex]) byKey[hex] = { key: k, want: 0, have: null };
|
|
1221
|
+
byKey[hex].want += 1;
|
|
1222
|
+
});
|
|
1223
|
+
Object.keys(byKey).forEach(function (hex) {
|
|
1224
|
+
var e = byKey[hex];
|
|
1225
|
+
e.have = _allMatching(bag || [], e.key);
|
|
1226
|
+
if (e.have.length < e.want) {
|
|
1227
|
+
throw E("est/no-issued-cert",
|
|
1228
|
+
"the /fullcmc " + arm + " response carried " + e.have.length + " certificate(s) for a public key " +
|
|
1229
|
+
e.want + " certification request(s) asked to have certified, so it does not answer this request " +
|
|
1230
|
+
"(RFC 5272 sec. 4.1)");
|
|
1231
|
+
}
|
|
1232
|
+
if (e.have.length > e.want) {
|
|
1233
|
+
throw E("est/ambiguous-issued-cert",
|
|
1234
|
+
"the /fullcmc response carried more certificates matching a submitted request key than there were " +
|
|
1235
|
+
"requests for it; the issued certificate is ambiguous (RFC 5272 sec. 4.1)");
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
// Each certificate handed out once. Where the requested keys are distinct this
|
|
1239
|
+
// lands in request order; where several requests share ONE key it cannot, and
|
|
1240
|
+
// does not pretend to -- the public key is the only identification RFC 5272
|
|
1241
|
+
// sec. 4.1 sanctions, so when it is shared nothing in the response says which
|
|
1242
|
+
// of those requests a given certificate answers. What is established either way
|
|
1243
|
+
// is the set: every requested key is answered, and by exactly as many
|
|
1244
|
+
// certificates as asked for it.
|
|
1245
|
+
return wanted.map(function (k) { return byKey[k.toString("hex")].have.shift(); });
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Every certificate in the bag whose subject public key is `key`, found through
|
|
1249
|
+
// the same public-key match the single-certificate case uses so the two cannot
|
|
1250
|
+
// disagree about what "matches" means.
|
|
1251
|
+
function _allMatching(bag, key) {
|
|
1252
|
+
var rest = bag.slice(), out = [], hit;
|
|
1253
|
+
while ((hit = findIssuedCert(rest, key)) !== null) {
|
|
1254
|
+
out.push(hit);
|
|
1255
|
+
rest = rest.filter(function (c) { return c !== hit; });
|
|
1256
|
+
}
|
|
1257
|
+
return out;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// The public keys the submitted Full PKI Request asked to have certified. A
|
|
1261
|
+
// certs-only answer carries no status and no request reference, so these are the
|
|
1262
|
+
// ONLY thing a returned certificate can be tied back to -- and RFC 5272 sec. 4.1
|
|
1263
|
+
// forbids identifying the issued certificate positionally ("the certificates are
|
|
1264
|
+
// in any order"). Both key-bearing request arms are read; an `orm` arm carries no
|
|
1265
|
+
// key and contributes none, and an arm that will not parse contributes none rather
|
|
1266
|
+
// than failing the exchange -- the caller still gets a refusal downstream, because
|
|
1267
|
+
// an empty set cannot match anything.
|
|
1268
|
+
function _requestedPublicKeys(der) {
|
|
1269
|
+
var out = [], body;
|
|
1270
|
+
try { body = cmcFmt.parse(der); }
|
|
1271
|
+
catch (e) {
|
|
1272
|
+
throw E("est/bad-input",
|
|
1273
|
+
"pki.est.fullcmc requires a Full PKI Request (id-cct-PKIData); this input did not parse as one", e);
|
|
1274
|
+
}
|
|
1275
|
+
if (body.kind !== "pkiData") {
|
|
1276
|
+
throw E("est/bad-input",
|
|
1277
|
+
"pki.est.fullcmc sends a Full PKI Request (id-cct-PKIData); this input is a " + body.kind);
|
|
1278
|
+
}
|
|
1279
|
+
(body.requests || []).forEach(function (r) {
|
|
1280
|
+
// A key-bearing arm whose key cannot be read must NOT quietly contribute
|
|
1281
|
+
// nothing. The CMC parser validates these arms only far enough to find their
|
|
1282
|
+
// identity, so a malformed one reaches here; dropping it would leave the
|
|
1283
|
+
// correlation blind to a request that was still sent, and a response covering
|
|
1284
|
+
// only the readable arms would then read as a complete issuance. Refused at
|
|
1285
|
+
// the entry point, like any other request this verb cannot stand behind.
|
|
1286
|
+
var before = out.length;
|
|
1287
|
+
var keyBearing = !!(r.certificationRequestBytes || r.certReqMsgBytes);
|
|
1288
|
+
try {
|
|
1289
|
+
if (r.certificationRequestBytes) {
|
|
1290
|
+
var spki = csr.parse(r.certificationRequestBytes).subjectPublicKeyInfo;
|
|
1291
|
+
if (spki && Buffer.isBuffer(spki.bytes)) out.push(spki.bytes);
|
|
1292
|
+
if (out.length === before) _unreadableArm();
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (r.certReqMsgBytes) {
|
|
1296
|
+
// pki.schema.crmf reads CertReqMessages (SEQUENCE OF CertReqMsg); the CMC
|
|
1297
|
+
// arm holds ONE CertReqMsg, so it is wrapped rather than decoded here --
|
|
1298
|
+
// the CRMF rules stay in the CRMF parser instead of being restated.
|
|
1299
|
+
var msgs = crmfFmt.parse(asn1.build.sequence([r.certReqMsgBytes])).messages;
|
|
1300
|
+
var msg0 = msgs && msgs[0];
|
|
1301
|
+
var tmpl = msg0 && msg0.certReq && msg0.certReq.certTemplate;
|
|
1302
|
+
// RFC 4211 sec. 4.1: the requested key may be absent from the CertTemplate
|
|
1303
|
+
// and carried in the signature POP's POPOSigningKeyInput instead -- a form
|
|
1304
|
+
// this toolkit's own CRMF parser accepts and surfaces. Reading only the
|
|
1305
|
+
// template would refuse a conforming request before it was ever sent.
|
|
1306
|
+
var pk = (tmpl && tmpl.publicKey) ||
|
|
1307
|
+
(msg0 && msg0.popo && msg0.popo.poposkInput && msg0.popo.poposkInput.publicKey);
|
|
1308
|
+
var pkBytes = Buffer.isBuffer(pk) ? pk : (pk && pk.bytes);
|
|
1309
|
+
if (Buffer.isBuffer(pkBytes)) out.push(pkBytes);
|
|
1310
|
+
if (out.length === before) _unreadableArm();
|
|
1311
|
+
}
|
|
1312
|
+
} catch (e) {
|
|
1313
|
+
if (!keyBearing) return; // an orm arm carries no key and is not expected to
|
|
1314
|
+
throw (e && e.code === "est/bad-input") ? e : E("est/bad-input",
|
|
1315
|
+
"a certification request in this Full PKI Request could not be read, so a response could not be " +
|
|
1316
|
+
"tied back to it; pki.est.fullcmc will not send a request it cannot check the answer to", e);
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
return out;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function _unreadableArm() {
|
|
1323
|
+
throw E("est/bad-input", "a certification request in this Full PKI Request declares no readable public key");
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// What the caller retained, threaded to pki.cmc.verify -- the exchange binding
|
|
1327
|
+
// AND the carrier-authentication inputs. Built in one place so the success and
|
|
1328
|
+
// rejection arms cannot drift apart about what this exchange is.
|
|
1329
|
+
// Captured SYNCHRONOUSLY, before the request goes out, and with the byte buffers
|
|
1330
|
+
// copied. Everything here is read again only after the transport resolves, so
|
|
1331
|
+
// reading the caller's live options at that point would let an object mutated
|
|
1332
|
+
// during the round trip decide the checks -- and flipping allowUnverifiedResponse
|
|
1333
|
+
// mid-flight would skip the signature check the default posture requires.
|
|
1334
|
+
// pki.cmc.verify freezes its own inputs too; this is the wrapper's half of the
|
|
1335
|
+
// same rule, because by the time it calls verify the damage would already be done.
|
|
1336
|
+
function _cmcSent(opts, der) {
|
|
1337
|
+
var carried = _requestBinding(der);
|
|
1338
|
+
// A caller-supplied value must AGREE with the request. Silently preferring one
|
|
1339
|
+
// over the other would leave the response checked against something the request
|
|
1340
|
+
// may never have carried; refusing the disagreement keeps the two honest.
|
|
1341
|
+
_assertAgrees("transactionId", opts.transactionId, carried.transactionId);
|
|
1342
|
+
_assertAgrees("senderNonce", opts.senderNonce, carried.senderNonce);
|
|
1343
|
+
_assertAgrees("dataReturn", opts.dataReturn, carried.dataReturn);
|
|
1344
|
+
return {
|
|
1345
|
+
transactionId: carried.transactionId,
|
|
1346
|
+
senderNonce: _copyBytes(carried.senderNonce),
|
|
1347
|
+
dataReturn: _copyBytes(carried.dataReturn),
|
|
1348
|
+
bodyPartIDs: carried.bodyPartIDs,
|
|
1349
|
+
// The nested paths travel with the flat identifiers: a status may name a body part inside a
|
|
1350
|
+
// nested message this request carried, and forwarding only the flat set would leave every
|
|
1351
|
+
// such reference unconfirmable and therefore refused.
|
|
1352
|
+
bodyPartPaths: carried.bodyPartPaths,
|
|
1353
|
+
certs: Array.isArray(opts.responderCerts) ? opts.responderCerts.map(_copyBytes) : opts.responderCerts,
|
|
1354
|
+
// The AuthenticatedData carrier's key material. Without this the verb could
|
|
1355
|
+
// reach that carrier only through the unauthenticated opt-out -- the capability
|
|
1356
|
+
// would exist one layer down and be unreachable from the one operators call.
|
|
1357
|
+
recipient: _copyRecipient(opts.responseRecipient),
|
|
1358
|
+
allowUnverified: opts.allowUnverifiedResponse === true,
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// EVERY byte form the parsers and crypto routines downstream accept, not just the two most
|
|
1363
|
+
// common: a DataView or a bare ArrayBuffer arrives by the same door as a Buffer, and the values
|
|
1364
|
+
// this copies -- the nonce the response must echo, the certificates that authenticate it, the
|
|
1365
|
+
// recipient key material -- are all read after the transport resolves. Covering only Buffer and
|
|
1366
|
+
// Uint8Array would leave those forms aliased across the request's whole flight, which is the same
|
|
1367
|
+
// window this exists to close, open for exactly the inputs that came in by the wider door.
|
|
1368
|
+
// A DataView is copied over its OWN window, not the whole backing buffer it happens to sit in.
|
|
1369
|
+
function _copyBytes(v) {
|
|
1370
|
+
if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.from(v);
|
|
1371
|
+
if (ArrayBuffer.isView(v)) return Buffer.from(new Uint8Array(v.buffer, v.byteOffset, v.byteLength));
|
|
1372
|
+
if (v instanceof ArrayBuffer) return Buffer.from(new Uint8Array(v));
|
|
1373
|
+
return v;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// The exchange-binding controls the request ACTUALLY carries (RFC 5272 sec. 6.6
|
|
1377
|
+
// Transaction Identifier / Sender Nonce, sec. 6.4 Data Return). These are what the
|
|
1378
|
+
// response has to echo, so they are read from the bytes going out rather than from
|
|
1379
|
+
// a parallel claim that could disagree with them.
|
|
1380
|
+
function _requestBinding(der) {
|
|
1381
|
+
var out = { transactionId: undefined, senderNonce: undefined, dataReturn: undefined, bodyPartIDs: undefined,
|
|
1382
|
+
bodyPartPaths: undefined };
|
|
1383
|
+
// Parsed WITHOUT a fallback. The caller reaches this only after the request has
|
|
1384
|
+
// already been confirmed to be a Full PKI Request, so a parse failure here is
|
|
1385
|
+
// impossible rather than expected -- and defaulting to "no binding" if it ever
|
|
1386
|
+
// became possible is the wrong answer anyway: a request whose controls could not
|
|
1387
|
+
// be read would face no echo requirement at all, which is a weaker exchange than
|
|
1388
|
+
// the one being sent. Letting the typed decode error propagate keeps it closed.
|
|
1389
|
+
var body = cmcFmt.parse(der);
|
|
1390
|
+
// Every identifier this request actually carries. A status in the response names
|
|
1391
|
+
// the body parts it is ABOUT, and a status naming a body part that was never
|
|
1392
|
+
// sent is not an answer to this request -- so the client keeps the set it can
|
|
1393
|
+
// check that claim against, the same way it keeps the transaction and nonce.
|
|
1394
|
+
// Collected from all four sequences because RFC 5272 sec. 3.2.1 draws every
|
|
1395
|
+
// identifier from ONE space; a status may legitimately reference a control or a
|
|
1396
|
+
// content info, not only a certification request.
|
|
1397
|
+
var ids = [];
|
|
1398
|
+
var paths = [];
|
|
1399
|
+
[body.requests, body.controls, body.cmsSequence, body.otherMsgs].forEach(function (list) {
|
|
1400
|
+
(list || []).forEach(function (el) {
|
|
1401
|
+
if (el && el.bodyPartID != null) { ids.push(el.bodyPartID); paths.push([el.bodyPartID]); }
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
// A cmsSequence element carries a NESTED CMS message, which may itself be a Full PKI
|
|
1405
|
+
// Request (sec. 3.2.1), and a response identifies a body part inside one by the path
|
|
1406
|
+
// [outer, inner...]. This client composed that nested message, so it can confirm such a
|
|
1407
|
+
// path -- but only by reading the message back, which is what this does. A nested message
|
|
1408
|
+
// this parser cannot read back yields no retained path, so a status naming a part inside
|
|
1409
|
+
// it is still refused: what cannot be read cannot be confirmed.
|
|
1410
|
+
_collectNestedPaths(body.cmsSequence, [], paths, 0);
|
|
1411
|
+
out.bodyPartIDs = ids;
|
|
1412
|
+
out.bodyPartPaths = paths;
|
|
1413
|
+
(body.controls || []).forEach(function (c) {
|
|
1414
|
+
var field = c.attrType === OID_CMC_TRANSACTION_ID ? "transactionId"
|
|
1415
|
+
: c.attrType === OID_CMC_SENDER_NONCE ? "senderNonce"
|
|
1416
|
+
: c.attrType === OID_CMC_DATA_RETURN ? "dataReturn" : null;
|
|
1417
|
+
if (!field) return; // not a binding control
|
|
1418
|
+
// Duplicates are ambiguous, and ambiguity here decides what the response must
|
|
1419
|
+
// echo: taking the last would bind the answer to one of two values the request
|
|
1420
|
+
// sent, chosen arbitrarily. pki.cmc.verify already refuses duplicate binding
|
|
1421
|
+
// controls on the response; the request side is held to the same rule rather
|
|
1422
|
+
// than shipping a message whose own binding cannot be read one way.
|
|
1423
|
+
if (out[field] !== undefined) {
|
|
1424
|
+
throw E("est/bad-input",
|
|
1425
|
+
"the Full PKI Request carries more than one " + field + " control, so there is no single value " +
|
|
1426
|
+
"the response can be bound to (RFC 5272 sec. 6.6 / 6.4)");
|
|
1427
|
+
}
|
|
1428
|
+
// A binding control that is PRESENT but unreadable must not decay into
|
|
1429
|
+
// "absent". Doing so would drop the replay check for a request that carries
|
|
1430
|
+
// the control -- the response would then face no echo requirement at all,
|
|
1431
|
+
// which is a weaker exchange than the one being sent. An unreadable security
|
|
1432
|
+
// control is a refusal, not a default.
|
|
1433
|
+
var v = c.values && c.values.length === 1 ? c.values[0] : null;
|
|
1434
|
+
if (!v) {
|
|
1435
|
+
throw E("est/bad-input",
|
|
1436
|
+
"the Full PKI Request's " + field + " control must carry exactly one value (RFC 5272 sec. 6.6 / 6.4)");
|
|
1437
|
+
}
|
|
1438
|
+
try {
|
|
1439
|
+
out[field] = field === "transactionId"
|
|
1440
|
+
? asn1.read.integer(asn1.decode(v))
|
|
1441
|
+
: asn1.read.octetString(asn1.decode(v));
|
|
1442
|
+
} catch (e) {
|
|
1443
|
+
throw E("est/bad-input",
|
|
1444
|
+
"the Full PKI Request's " + field + " control could not be read, so the response could not be " +
|
|
1445
|
+
"checked against it; a request is not sent under a binding that cannot be enforced", e);
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
return out;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// Every body-part path reachable inside the nested messages this request carries, so a status
|
|
1452
|
+
// naming one can be confirmed rather than refused outright. Descends only into a nested message
|
|
1453
|
+
// this parser reads back as a PKIData: anything else yields no path, and a status naming a part
|
|
1454
|
+
// inside it stays a refusal, because a reference that cannot be checked is not one to accept.
|
|
1455
|
+
// The depth cap bounds the walk; a request nested deeper than this is composed by nobody in
|
|
1456
|
+
// practice, and stopping simply leaves those paths unretained -- which refuses, not accepts.
|
|
1457
|
+
var NESTED_PATH_DEPTH_CAP = 8;
|
|
1458
|
+
function _collectNestedPaths(list, prefix, out, depth) {
|
|
1459
|
+
if (depth >= NESTED_PATH_DEPTH_CAP) return;
|
|
1460
|
+
(list || []).forEach(function (el) {
|
|
1461
|
+
if (!el || el.bodyPartID == null || !el.contentInfoBytes) return;
|
|
1462
|
+
var inner;
|
|
1463
|
+
// A nested element that cannot be read back as a Full PKI Request contributes no path, by
|
|
1464
|
+
// either route: the read throws, or it succeeds and yields some other content type. Both land
|
|
1465
|
+
// on the same refusal downstream -- a status naming a part inside it finds no retained path --
|
|
1466
|
+
// so neither can widen what the response may report on. Nothing is absorbed here that a
|
|
1467
|
+
// verdict then rests on; the absence of a path IS the fail-closed answer.
|
|
1468
|
+
try { inner = cmcFmt.parse(el.contentInfoBytes); }
|
|
1469
|
+
catch (_e) { /* allow:swallow-unverified the outer request must itself parse for this to run, which already rejects a malformed nested ContentInfo; a payload that survives that and still fails to read back lands on the same no-path refusal the non-pkiData route below takes, which the vectors drive */ return; }
|
|
1470
|
+
if (!inner || inner.kind !== "pkiData") return;
|
|
1471
|
+
var path = prefix.concat([el.bodyPartID]);
|
|
1472
|
+
[inner.requests, inner.controls, inner.cmsSequence, inner.otherMsgs].forEach(function (l) {
|
|
1473
|
+
(l || []).forEach(function (e2) { if (e2 && e2.bodyPartID != null) out.push(path.concat([e2.bodyPartID])); });
|
|
1474
|
+
});
|
|
1475
|
+
_collectNestedPaths(inner.cmsSequence, path, out, depth + 1);
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function _assertAgrees(name, supplied, carried) {
|
|
1480
|
+
if (supplied == null) return;
|
|
1481
|
+
var same;
|
|
1482
|
+
if (Buffer.isBuffer(supplied) || supplied instanceof Uint8Array) {
|
|
1483
|
+
same = Buffer.isBuffer(carried) && Buffer.from(supplied).equals(carried);
|
|
1484
|
+
} else {
|
|
1485
|
+
// Through the shared authoring guard, not a bare BigInt(): a `number` above
|
|
1486
|
+
// Number.MAX_SAFE_INTEGER has already lost the digits that distinguish it from
|
|
1487
|
+
// its neighbour, so converting it here would compare a value the caller never
|
|
1488
|
+
// wrote. A transaction identifier is an unbounded INTEGER on the wire; large
|
|
1489
|
+
// ones are passed as a bigint, and this says so rather than rounding.
|
|
1490
|
+
same = carried != null &&
|
|
1491
|
+
guard.range.authoredInteger(supplied, E, "est/bad-input", "opts." + name) === BigInt(carried);
|
|
1492
|
+
}
|
|
1493
|
+
if (!same) {
|
|
1494
|
+
throw E("est/bad-input",
|
|
1495
|
+
"opts." + name + " does not match the " + name + " control the Full PKI Request carries; the " +
|
|
1496
|
+
"response is checked against what was sent, so the two must agree (RFC 5272 sec. 6.6 / 6.4)");
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// The recipient descriptor, one level deep with its byte values copied -- key
|
|
1501
|
+
// material is read after the round trip like everything else in this snapshot.
|
|
1502
|
+
function _copyRecipient(r) {
|
|
1503
|
+
if (!r || typeof r !== "object") return r;
|
|
1504
|
+
var out = {}, k;
|
|
1505
|
+
for (k in r) { if (Object.prototype.hasOwnProperty.call(r, k)) out[k] = _copyBytes(r[k]); }
|
|
1506
|
+
return out;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// Own enumerable properties, one level, plus a copy of `auth`. The extra level is
|
|
1510
|
+
// not decoration: credentials are the one nested object read AFTER an await --
|
|
1511
|
+
// _drive reads the scheme, the username and password, and the Digest policy only
|
|
1512
|
+
// once a 401 comes back -- so leaving it shared would let an options object
|
|
1513
|
+
// mutated during the round trip change which credentials go out, or turn on a
|
|
1514
|
+
// digest algorithm the request did not start with. Everything else here is either
|
|
1515
|
+
// read before the request leaves or is a value the caller means to own (a
|
|
1516
|
+
// transport function, a tls config).
|
|
1517
|
+
function _shallowCopy(o) {
|
|
1518
|
+
var out = {}, k;
|
|
1519
|
+
for (k in o) { if (Object.prototype.hasOwnProperty.call(o, k)) out[k] = o[k]; }
|
|
1520
|
+
if (out.auth && typeof out.auth === "object") {
|
|
1521
|
+
var a = {}, ak;
|
|
1522
|
+
for (ak in out.auth) { if (Object.prototype.hasOwnProperty.call(out.auth, ak)) a[ak] = out.auth[ak]; }
|
|
1523
|
+
out.auth = a;
|
|
1524
|
+
}
|
|
1525
|
+
return out;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// The Full PKI Request bytes: a DER Buffer, or a PEM CMS block. Anything else is
|
|
1529
|
+
// a caller mistake caught before the transport is touched.
|
|
1530
|
+
function _cmcRequestDer(request) {
|
|
1531
|
+
// COPIED, not aliased: these bytes are parsed now (for the requested keys) and
|
|
1532
|
+
// transmitted later, so sharing the caller's buffer would let the two disagree.
|
|
1533
|
+
if (Buffer.isBuffer(request)) return Buffer.from(request);
|
|
1534
|
+
if (request instanceof Uint8Array) return Buffer.from(request);
|
|
1535
|
+
if (typeof request === "string") return cms.pemDecode(request);
|
|
1536
|
+
throw E("est/bad-input", "pki.est.fullcmc requires the Full PKI Request as DER bytes or a PEM CMS block");
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// The result function for /fullcmc. Deliberately NOT _certsResult: the two differ
|
|
1540
|
+
// on the point RFC 7030 sec. 4.2.3 and sec. 4.3.2 disagree about. On the sibling
|
|
1541
|
+
// enroll verbs a Simple PKI Response on an error only MAY be present; on
|
|
1542
|
+
// /fullcmc "A CMC response with the content-type of application/pkcs7-mime MUST
|
|
1543
|
+
// be included in the response data for any CMC error response", so the CMC
|
|
1544
|
+
// verdict is decoded and surfaced. What must NOT happen is the decode failing and
|
|
1545
|
+
// taking the HTTP fault with it -- an unreadable error body leaves the fault
|
|
1546
|
+
// reported as itself.
|
|
1547
|
+
function _fullcmcResult(res, opts, wanted, sent) {
|
|
1548
|
+
// classifyResponse is the single authority on what an HTTP status means here,
|
|
1549
|
+
// and it THROWS est/http-error on a 4xx/5xx rather than returning a verdict.
|
|
1550
|
+
// So the rejection path runs from its exception: the mandated CMC response is
|
|
1551
|
+
// decoded and, if readable, REPLACES the fault with a richer typed one. If it
|
|
1552
|
+
// cannot be read, the original fault is rethrown untouched -- an unreadable
|
|
1553
|
+
// error body is a worse fault than the status, never a way to lose it.
|
|
1554
|
+
var verdict;
|
|
1555
|
+
try {
|
|
1556
|
+
verdict = classifyResponse(res.status, res.headers, res.body, { op: "fullcmc", now: opts.now });
|
|
1557
|
+
} catch (httpFault) {
|
|
1558
|
+
// ONLY an actual rejection status earns the richer error. classifyResponse also
|
|
1559
|
+
// throws when a NON-error response fails its own validation -- a 200 with a
|
|
1560
|
+
// missing or unrecognized smime-type, a 202 with no Retry-After -- and those
|
|
1561
|
+
// faults are about the response being malformed, not about the CA refusing.
|
|
1562
|
+
// Letting a signed CMC body replace them would let a malformed 200 answer as
|
|
1563
|
+
// though it were a clean rejection, and the validation it failed would never be
|
|
1564
|
+
// reported. The status is read from the response, not from the fault, so a
|
|
1565
|
+
// future fault type cannot quietly opt itself into the upgrade.
|
|
1566
|
+
if (!(res.status >= 400 && res.status <= 599)) throw httpFault;
|
|
1567
|
+
return _tryDecodeCmcFault(res, sent).then(function (cmcErr) { throw cmcErr || httpFault; });
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
if (verdict.status === "not-implemented") {
|
|
1571
|
+
// Takes precedence over any body: the service is absent, so there is no CMC
|
|
1572
|
+
// verdict to read (RFC 7030 sec. 4.3.2 -- 404 or 501).
|
|
1573
|
+
throw E("est/not-implemented",
|
|
1574
|
+
"the EST server does not implement /fullcmc (HTTP " + verdict.httpStatus + "; RFC 7030 sec. 4.3.2)");
|
|
1575
|
+
}
|
|
1576
|
+
if (verdict.status === "retry") {
|
|
1577
|
+
return { retry: true, retryAfterSeconds: verdict.retryAfterSeconds, retryAfterDate: verdict.retryAfterDate };
|
|
1578
|
+
}
|
|
1579
|
+
if (verdict.status !== "ok") {
|
|
1580
|
+
throw E("est/http-error",
|
|
1581
|
+
"an EST /fullcmc response must be HTTP 200 or 202 (RFC 7030 sec. 4.3.2), got " + res.status);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
var bodyLen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
|
|
1585
|
+
if (bodyLen === 0) throw E("est/empty-body", "a 200 /fullcmc response carried an empty body (RFC 7030 sec. 4.3.2)");
|
|
1586
|
+
var der = transferDecode(res.body);
|
|
1587
|
+
|
|
1588
|
+
// FC5 admits two smime-types, and the LABEL must agree with the BYTES: a
|
|
1589
|
+
// certs-only label over a Full PKI Response (or the reverse) is a server
|
|
1590
|
+
// mislabelling its own body, and accepting either shape under either label
|
|
1591
|
+
// would make the content-type check decorative.
|
|
1592
|
+
// Through the shared case-insensitive accessor: HTTP header names are
|
|
1593
|
+
// case-insensitive (RFC 9110 sec. 5.1), the classifier already reads them that
|
|
1594
|
+
// way, and a second lookup that guessed at two spellings would disagree with it
|
|
1595
|
+
// for a conformant server -- routing a certs-only body down the wrong arm.
|
|
1596
|
+
var pt200 = _partMediaType(_ciHeader(res.headers, "content-type"));
|
|
1597
|
+
// A header declaring BOTH arms selects neither. Taking the first would let the
|
|
1598
|
+
// order of two labels decide which shape the body is read as, on a header whose
|
|
1599
|
+
// whole job here is to say which one it is.
|
|
1600
|
+
if (pt200.ambiguous) {
|
|
1601
|
+
throw E("est/bad-content-type",
|
|
1602
|
+
"the 200 /fullcmc Content-Type declares more than one smime-type, so which response arm this is " +
|
|
1603
|
+
"cannot be told from it (RFC 7030 sec. 4.3.2)");
|
|
1604
|
+
}
|
|
1605
|
+
var smimeType = pt200.smimeType;
|
|
1606
|
+
if (smimeType === "certs-only") {
|
|
1607
|
+
// A certs-only body is a degenerate certificates-only SignedData: it carries no
|
|
1608
|
+
// CMC controls, so it CANNOT echo a Transaction Identifier, Sender Nonce or
|
|
1609
|
+
// Data Return. A client that sent those asked for replay binding, and the
|
|
1610
|
+
// public-key correlation below is not one -- an old response for the same key
|
|
1611
|
+
// still matches. Accepting this arm would silently give none of what was asked
|
|
1612
|
+
// for, so it is refused; a caller who does not need the binding simply does not
|
|
1613
|
+
// send the controls.
|
|
1614
|
+
var unecho = ["transactionId", "senderNonce", "dataReturn"].filter(function (k) {
|
|
1615
|
+
return sent[k] != null;
|
|
1616
|
+
});
|
|
1617
|
+
if (unecho.length) {
|
|
1618
|
+
throw E("est/unbound-response",
|
|
1619
|
+
"the request carried " + unecho.join(" / ") + ", which a certs-only response has no controls to " +
|
|
1620
|
+
"echo, so the replay binding it asked for cannot be checked (RFC 5272 sec. 6.6 / 6.4)");
|
|
1621
|
+
}
|
|
1622
|
+
var certs = parseCertsOnly(der); // throws est/not-certs-only on a Full PKI Response
|
|
1623
|
+
// A certs-only body says nothing about WHICH request it answers -- no status,
|
|
1624
|
+
// no body-part reference. So the issued certificate is identified the one way
|
|
1625
|
+
// RFC 5272 sec. 4.1 sanctions, by public-key match against what was submitted,
|
|
1626
|
+
// exactly as /simpleenroll and /serverkeygen already do. Without this, a bag
|
|
1627
|
+
// holding only a CA chain -- or a certificate for someone else's key -- reads
|
|
1628
|
+
// as a successful issuance for this request.
|
|
1629
|
+
var issuedCerts = _correlateIssued(certs.certificates, wanted, "certs-only");
|
|
1630
|
+
var issued = issuedCerts[0];
|
|
1631
|
+
// `signatureVerified: false` is stated, not omitted. A certs-only body is a
|
|
1632
|
+
// DEGENERATE certificates-only SignedData -- RFC 5652 sec. 5.2 defines it with
|
|
1633
|
+
// an empty signerInfos set -- so there is no signature here to check, and
|
|
1634
|
+
// demanding one would reject every conformant server. What this arm must not
|
|
1635
|
+
// do is leave the field undefined while the cmc-response arm sets it: a caller
|
|
1636
|
+
// reading `verdict.signatureVerified` would then get a different KIND of answer
|
|
1637
|
+
// depending on which arm the server happened to choose. RFC 7030 secures this
|
|
1638
|
+
// arm through the authenticated TLS channel instead, and the certificates stay
|
|
1639
|
+
// `trusted: false` for the caller to run through pki.path.validate.
|
|
1640
|
+
return { outcome: "issued", certificate: issued, issuedCertificates: issuedCerts,
|
|
1641
|
+
certificates: certs.certificates, crls: certs.crls,
|
|
1642
|
+
controls: [], statuses: [], publishTrustAnchors: null, trusted: false,
|
|
1643
|
+
signatureVerified: false };
|
|
1644
|
+
}
|
|
1645
|
+
// smime-type=cmc-response: the Full PKI Response, interpreted into one verdict.
|
|
1646
|
+
// pki.cmc.verify owns the transaction binding and the status reduction; this
|
|
1647
|
+
// verb only supplies what the caller retained.
|
|
1648
|
+
return cmcVerify.verify(der, sent).then(function (verdict) {
|
|
1649
|
+
// An `issued` verdict is a claim about THIS request, so it is held to the same
|
|
1650
|
+
// correlation as the certs-only arm. A status control saying success while the
|
|
1651
|
+
// bag holds a CA chain, someone else's certificate, or nothing at all does not
|
|
1652
|
+
// make an issuance -- and the status is the server's word, whereas the key
|
|
1653
|
+
// match is checkable. Only `issued` is correlated: pending / pop-required /
|
|
1654
|
+
// confirm-required / rejected are not claims that a certificate was issued.
|
|
1655
|
+
if (verdict.outcome !== "issued") return verdict;
|
|
1656
|
+
// A request that asked for no certificate cannot have one correlated to it. An
|
|
1657
|
+
// orm-only Full PKI Request is exactly that: the other-message arm carries no
|
|
1658
|
+
// certification request and no key, and RFC 5272 lets a server answer it
|
|
1659
|
+
// successfully with its result in the cmsSequence or otherMsgSequence rather
|
|
1660
|
+
// than as an issuance. Reading `issued` there as "a certificate must match"
|
|
1661
|
+
// would make this verb unable to carry that exchange at all. The response's own
|
|
1662
|
+
// certificate bag is still surfaced raw, untrusted, for pki.path.validate.
|
|
1663
|
+
//
|
|
1664
|
+
// The certs-only arm keeps the opposite rule, and deliberately: that body is
|
|
1665
|
+
// nothing BUT a claim of certificate issuance, so with nothing to match it
|
|
1666
|
+
// against there is no way to say it answers this request.
|
|
1667
|
+
if (!wanted.length) { verdict.issuedCertificates = []; return verdict; }
|
|
1668
|
+
var issuedCerts = _correlateIssued(verdict.certificates, wanted, "CMC-response");
|
|
1669
|
+
verdict.certificate = issuedCerts[0];
|
|
1670
|
+
verdict.issuedCertificates = issuedCerts;
|
|
1671
|
+
return verdict;
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// Decode the CMC response a rejection MUST carry, returning a typed est/cmc-failed
|
|
1676
|
+
// with the verdict attached -- or null when it cannot be read, so the caller
|
|
1677
|
+
// reports the HTTP fault instead of an asn1/* leak from a body that was never
|
|
1678
|
+
// going to parse.
|
|
1679
|
+
function _tryDecodeCmcFault(res, sent) {
|
|
1680
|
+
return Promise.resolve().then(function () {
|
|
1681
|
+
var pt = _partMediaType(_ciHeader(res.headers, "content-type"));
|
|
1682
|
+
if (pt.media !== "application/pkcs7-mime") return null;
|
|
1683
|
+
// A header declaring two smime-types cannot vouch for the body either. This
|
|
1684
|
+
// path returns NULL rather than throwing, so the caller reports the HTTP fault
|
|
1685
|
+
// the response actually carried -- replacing a real server error with a
|
|
1686
|
+
// content-type complaint would hide the thing the operator needs to see.
|
|
1687
|
+
if (pt.ambiguous) return null;
|
|
1688
|
+
// The LABEL must agree with the bytes here too. The success path refuses a
|
|
1689
|
+
// certs-only label over a Full PKI Response and the reverse; accepting any
|
|
1690
|
+
// pkcs7-mime on the error path would make that agreement decorative, and would
|
|
1691
|
+
// read a CMC verdict out of a body the server said was something else. RFC 7030
|
|
1692
|
+
// sec. 4.3.2 makes the rejection body a CMC-response.
|
|
1693
|
+
if (String(pt.smimeType || "").toLowerCase() !== "cmc-response") return null;
|
|
1694
|
+
// The SAME binding the success path applies. An error response that does not
|
|
1695
|
+
// echo this exchange's transaction / nonce is not this request's answer, and
|
|
1696
|
+
// attaching it would let a replayed or unrelated failure be reported as the
|
|
1697
|
+
// verdict for the request just sent. A binding failure makes verify reject,
|
|
1698
|
+
// which lands in the catch below and leaves the HTTP fault reported.
|
|
1699
|
+
return cmcVerify.verify(transferDecode(res.body), sent).then(function (verdict) {
|
|
1700
|
+
// Only an actual REJECTION becomes the richer error. A CMC body inside an
|
|
1701
|
+
// HTTP failure that says the request was issued is the server contradicting
|
|
1702
|
+
// itself, and reporting it as "the server rejected the Full PKI Request:
|
|
1703
|
+
// issued" would hand the caller a successful outcome wrapped in a rejection.
|
|
1704
|
+
// Returning null there leaves the HTTP fault standing, which is the honest
|
|
1705
|
+
// report of what happened. RFC 7030 sec. 4.3.2 makes the body a MUST for a
|
|
1706
|
+
// rejection, which is the case this upgrade exists for.
|
|
1707
|
+
if (verdict.outcome !== "rejected") return null;
|
|
1708
|
+
var e = E("est/cmc-failed",
|
|
1709
|
+
"the EST server rejected the Full PKI Request: " + verdict.outcome +
|
|
1710
|
+
(verdict.failInfo ? " (" + verdict.failInfo + ")" : "") + " [HTTP " + res.status + "]");
|
|
1711
|
+
e.cmc = verdict;
|
|
1712
|
+
e.httpStatus = res.status;
|
|
1713
|
+
return e;
|
|
1714
|
+
});
|
|
1715
|
+
// EVERY way the attempt can fail lands here, not just the verify rejection:
|
|
1716
|
+
// transferDecode throws synchronously on a non-base64 body, which is exactly
|
|
1717
|
+
// the shape FC7a is about. Returning null makes the caller rethrow the ORIGINAL
|
|
1718
|
+
// HTTP fault -- nothing is absorbed into a verdict, and the richer error is
|
|
1719
|
+
// only ever an upgrade, never a substitute that loses the status.
|
|
1720
|
+
}).then(null, function () { return null; });
|
|
1721
|
+
}
|
|
1722
|
+
|
|
963
1723
|
function _enroll(op, baseUrl, csrInput, opts) {
|
|
964
1724
|
var csrDer = _csrDer(csrInput);
|
|
965
1725
|
var spki = csr.parse(csrDer).subjectPublicKeyInfo;
|
|
@@ -986,7 +1746,7 @@ function _enroll(op, baseUrl, csrInput, opts) {
|
|
|
986
1746
|
* anchor on the next call.
|
|
987
1747
|
*
|
|
988
1748
|
* @opts
|
|
989
|
-
* - `transport` -- an injected transport(request) -> {status, headers, body}; default pki.transport.https.
|
|
1749
|
+
* - `transport` -- an injected transport(request) -> {status, headers, body, tls}; default pki.transport.https.
|
|
990
1750
|
* - `tls` -- { anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity }.
|
|
991
1751
|
* - `label` -- an OPTIONAL CA label path segment; `timeout` / `maxResponseBytes` / `maxRedirects` -- budgets.
|
|
992
1752
|
* - `now` -- receipt time (epoch ms) to render a 202 Retry-After HTTP-date as seconds.
|
|
@@ -1114,12 +1874,47 @@ function _assertConfidentialCipher(res) {
|
|
|
1114
1874
|
// A case-insensitive header lookup: the injectable transport seam only promises { status, headers, body }, so a
|
|
1115
1875
|
// hostile / non-Node transport may deliver "Content-Type" in any casing -- read it case-insensitively (classifyResponse
|
|
1116
1876
|
// normalizes internally, but the multipart boundary is read from the header directly here).
|
|
1877
|
+
// A header, read case-insensitively (RFC 9110 sec. 5.1) -- and refused when the map
|
|
1878
|
+
// carries the SAME field under more than one spelling. HTTP header names are
|
|
1879
|
+
// case-insensitive, so `content-type` and `Content-Type` are one field with two
|
|
1880
|
+
// values; a reader that prefers the exact key and a reader that lowercases into a
|
|
1881
|
+
// map keep different ones, and this verb has both. Two stages that disagree about
|
|
1882
|
+
// which response arm a body is can accept a response the header declares twice
|
|
1883
|
+
// over. That is the same ambiguity a repeated Content-Type PARAMETER raises, at the
|
|
1884
|
+
// level of the field itself, and it gets the same answer: refused, not resolved by
|
|
1885
|
+
// which spelling was reached first.
|
|
1886
|
+
// A LIST field, read case-insensitively and combined across every spelling it
|
|
1887
|
+
// arrived under. RFC 9110 sec. 5.3 lets a recipient join multiple lines of a
|
|
1888
|
+
// list-based field into one comma-separated value, and sec. 11.6.1 makes
|
|
1889
|
+
// WWW-Authenticate exactly that -- one challenge per element. Refusing repetition
|
|
1890
|
+
// here would reject a server offering both Digest and Basic, which is not an
|
|
1891
|
+
// ambiguity but the field doing its job.
|
|
1892
|
+
function _ciHeaderList(headers, name) {
|
|
1893
|
+
headers = headers || {};
|
|
1894
|
+
var lname = name.toLowerCase(), keys = Object.keys(headers), parts = [];
|
|
1895
|
+
for (var i = 0; i < keys.length; i++) {
|
|
1896
|
+
if (keys[i].toLowerCase() !== lname) continue;
|
|
1897
|
+
var v = headers[keys[i]];
|
|
1898
|
+
if (v == null) continue;
|
|
1899
|
+
parts.push(Array.isArray(v) ? v.join(", ") : String(v));
|
|
1900
|
+
}
|
|
1901
|
+
return parts.length ? parts.join(", ") : null;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1117
1904
|
function _ciHeader(headers, name) {
|
|
1118
1905
|
headers = headers || {};
|
|
1119
|
-
|
|
1120
|
-
var
|
|
1121
|
-
|
|
1122
|
-
|
|
1906
|
+
var lname = name.toLowerCase(), keys = Object.keys(headers), found = null, n = 0;
|
|
1907
|
+
for (var i = 0; i < keys.length; i++) {
|
|
1908
|
+
if (keys[i].toLowerCase() !== lname) continue;
|
|
1909
|
+
n += 1;
|
|
1910
|
+
if (n === 1) found = headers[keys[i]];
|
|
1911
|
+
}
|
|
1912
|
+
if (n > 1) {
|
|
1913
|
+
throw E("est/bad-content-type",
|
|
1914
|
+
"the response carries more than one " + lname + " header field, so what the body is cannot be told " +
|
|
1915
|
+
"from it (RFC 9110 sec. 5.1: field names are case-insensitive)");
|
|
1916
|
+
}
|
|
1917
|
+
return n === 0 ? null : found;
|
|
1123
1918
|
}
|
|
1124
1919
|
|
|
1125
1920
|
async function _serverkeygenResult(res, opts, derived) {
|
|
@@ -1285,4 +2080,5 @@ module.exports = {
|
|
|
1285
2080
|
smimeCapabilitiesAttr: smimeCapabilitiesAttr,
|
|
1286
2081
|
buildEnrollAttributes: buildEnrollAttributes,
|
|
1287
2082
|
reenrollGuard: reenrollGuard,
|
|
2083
|
+
fullcmc: fullcmc,
|
|
1288
2084
|
};
|