@blamejs/pki 0.1.23 → 0.1.24
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 +13 -1
- package/README.md +2 -0
- package/index.js +6 -0
- package/lib/est.js +717 -0
- package/lib/framework-error.js +17 -0
- package/lib/oid.js +6 -1
- package/lib/schema-all.js +16 -0
- package/lib/schema-csr.js +17 -3
- package/lib/schema-csrattrs.js +371 -0
- package/lib/schema-pkix.js +12 -5
- package/lib/webcrypto.js +5 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/est.js
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.est
|
|
6
|
+
* @nav Schema
|
|
7
|
+
* @title EST
|
|
8
|
+
* @order 190
|
|
9
|
+
* @slug est
|
|
10
|
+
*
|
|
11
|
+
* @intro
|
|
12
|
+
* Enrollment over Secure Transport (RFC 7030, updated by RFC 8951 and
|
|
13
|
+
* RFC 9908) -- the transport-agnostic codecs, validators, and request builders
|
|
14
|
+
* an EST client composes over the shipped CMS / CSR / PKCS#8 / X.509 parsers.
|
|
15
|
+
* No socket is opened here: `transferDecode` / `transferEncode` are the RFC 8951
|
|
16
|
+
* sec. 3 base64 transfer codec (RFC 4648, and DELIBERATELY blind to any
|
|
17
|
+
* Content-Transfer-Encoding header -- errata 5904/5107); `splitMultipartMixed`
|
|
18
|
+
* is the /serverkeygen `multipart/mixed` splitter; `parseCertsOnly` validates a
|
|
19
|
+
* certs-only Simple PKI Response (RFC 5272 sec. 4.1) OVER `cms.parse` output;
|
|
20
|
+
* `parseServerKeygenResponse` dispatches the two-part key + certificate
|
|
21
|
+
* response with recipient-arm coherence; `classifyResponse` is the HTTP status
|
|
22
|
+
* / content-type / Retry-After state machine (202 accepted-not-ready surfaces
|
|
23
|
+
* `retryAfterSeconds` -- never an internal sleep; 204/404 on /csrattrs is a
|
|
24
|
+
* "none available" verdict, not an error). The builders assemble the CSR
|
|
25
|
+
* attributes EST adds -- a channel-binding challengePassword, the
|
|
26
|
+
* out-of-band-key identifiers, SMIMECapabilities, and the RFC 9908
|
|
27
|
+
* template-priority enroll plan.
|
|
28
|
+
*
|
|
29
|
+
* Altitude MATCHES the toolkit: structural validation, no crypto verdicts.
|
|
30
|
+
* Certificates come back RAW and UNORDERED ("Clients MUST NOT assume the
|
|
31
|
+
* certificates are in any order", RFC 5272 sec. 4.1) -- `findIssuedCert` picks
|
|
32
|
+
* the issued certificate by a public-key match, never a positional guess. The
|
|
33
|
+
* serverkeygen encrypted-key part's EnvelopedData is surfaced structurally
|
|
34
|
+
* (ciphertext raw, decryption external). /fullcmc is recognized and rejected
|
|
35
|
+
* with a precise `est/fullcmc-not-supported` (deferred to the CMC format
|
|
36
|
+
* module). DER-only where DER, fail-closed everywhere.
|
|
37
|
+
*
|
|
38
|
+
* @card
|
|
39
|
+
* EST (RFC 7030 / 8951 / 9908) client codecs -- base64 transfer, multipart
|
|
40
|
+
* splitter, certs-only + serverkeygen validators over CMS, the enroll-attribute
|
|
41
|
+
* builders, and the HTTP response classifier. Transport-agnostic, fail-closed.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
var asn1 = require("./asn1-der");
|
|
45
|
+
var oid = require("./oid");
|
|
46
|
+
var constants = require("./constants");
|
|
47
|
+
var cms = require("./schema-cms");
|
|
48
|
+
var x509 = require("./schema-x509");
|
|
49
|
+
var crl = require("./schema-crl");
|
|
50
|
+
var pkcs8 = require("./schema-pkcs8");
|
|
51
|
+
var csr = require("./schema-csr");
|
|
52
|
+
var frameworkError = require("./framework-error");
|
|
53
|
+
|
|
54
|
+
var EstError = frameworkError.EstError;
|
|
55
|
+
function E(code, message, cause) { return new EstError(code, message, cause); }
|
|
56
|
+
|
|
57
|
+
var ID_DATA = oid.byName("data");
|
|
58
|
+
var ID_SIGNED_DATA = oid.byName("signedData");
|
|
59
|
+
var OID_CHALLENGE_PASSWORD = oid.byName("challengePassword");
|
|
60
|
+
var OID_DECRYPT_KEY_ID = oid.byName("decryptKeyID");
|
|
61
|
+
var OID_ASYMM_DECRYPT_KEY_ID = oid.byName("asymmDecryptKeyID");
|
|
62
|
+
var OID_SMIME_CAPABILITIES = oid.byName("smimeCapabilities");
|
|
63
|
+
var OID_TEMPLATE = oid.byName("certificationRequestInfoTemplate");
|
|
64
|
+
|
|
65
|
+
var OPERATIONS = ["cacerts", "simpleenroll", "simplereenroll", "fullcmc", "serverkeygen", "csrattrs"];
|
|
66
|
+
|
|
67
|
+
// The largest Retry-After delay this classifier will surface as a number: a
|
|
68
|
+
// generous one-year ceiling that keeps the value a safe integer and rejects an
|
|
69
|
+
// overflowing / nonsensical delay-seconds fail-closed rather than returning it.
|
|
70
|
+
var MAX_RETRY_AFTER_SECONDS = constants.TIME.days(365) / constants.TIME.seconds(1);
|
|
71
|
+
|
|
72
|
+
// The three HTTP-date forms (RFC 7231 sec. 7.1.1.1): IMF-fixdate (the required
|
|
73
|
+
// form), and the obsolete rfc850-date / asctime-date a recipient must still
|
|
74
|
+
// accept. Gating Date.parse on this grammar keeps its permissiveness (an ISO
|
|
75
|
+
// string like "2026-07-10") from passing as a valid Retry-After header.
|
|
76
|
+
var _MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
|
|
77
|
+
var _MONTH = "(?:" + _MONTHS.join("|") + ")";
|
|
78
|
+
var HTTP_DATE_FORMS = [
|
|
79
|
+
new RegExp("^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \\d{2} " + _MONTH + " \\d{4} \\d{2}:\\d{2}:\\d{2} GMT$"),
|
|
80
|
+
new RegExp("^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \\d{2}-" + _MONTH + "-\\d{2} \\d{2}:\\d{2}:\\d{2} GMT$"),
|
|
81
|
+
new RegExp("^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) " + _MONTH + " [ \\d]\\d \\d{2}:\\d{2}:\\d{2} \\d{4}$"),
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// Parse an HTTP-date to epoch ms, or NaN when its grammar, calendar, or time is
|
|
85
|
+
// invalid. Built from the extracted fields via Date.UTC (never Date.parse): every
|
|
86
|
+
// HTTP-date is UTC -- the asctime form carries no GMT token, so delegating would
|
|
87
|
+
// parse it in LOCAL time -- and the round-trip check rejects an impossible date /
|
|
88
|
+
// time (Date.UTC normalizes Feb 31 -> Mar 2 just as Date.parse would).
|
|
89
|
+
function _httpDateMs(s) {
|
|
90
|
+
if (!HTTP_DATE_FORMS.some(function (re) { return re.test(s); })) return NaN;
|
|
91
|
+
var day, monName, year;
|
|
92
|
+
var asc = /^\w{3} (\w{3}) ([ \d]\d) /.exec(s); // asctime: month then day
|
|
93
|
+
if (asc) { monName = asc[1]; day = parseInt(asc[2], 10); year = parseInt(/(\d{4})$/.exec(s)[1], 10); }
|
|
94
|
+
else {
|
|
95
|
+
var dmy = /(\d{2})[ -](\w{3})[ -](\d{2,4})/.exec(s); // IMF / rfc850: day month year
|
|
96
|
+
if (!dmy) return NaN;
|
|
97
|
+
day = parseInt(dmy[1], 10); monName = dmy[2]; year = parseInt(dmy[3], 10);
|
|
98
|
+
if (year < 100) year += (year < 70 ? 2000 : 1900); // rfc850 two-digit year
|
|
99
|
+
}
|
|
100
|
+
var t = /(\d{2}):(\d{2}):(\d{2})/.exec(s);
|
|
101
|
+
var mon = _MONTHS.indexOf(monName);
|
|
102
|
+
if (!t || mon < 0) return NaN;
|
|
103
|
+
var hh = parseInt(t[1], 10), mi = parseInt(t[2], 10), ss = parseInt(t[3], 10);
|
|
104
|
+
var when = Date.UTC(year, mon, day, hh, mi, ss);
|
|
105
|
+
var d = new Date(when);
|
|
106
|
+
if (d.getUTCDate() !== day || d.getUTCMonth() !== mon || d.getUTCFullYear() !== year ||
|
|
107
|
+
d.getUTCHours() !== hh || d.getUTCMinutes() !== mi || d.getUTCSeconds() !== ss) return NaN;
|
|
108
|
+
return when;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- the RFC 8951 sec. 3/3.1 transfer codec (CTE-header-blind) -----------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @primitive pki.est.transferDecode
|
|
115
|
+
* @signature pki.est.transferDecode(body) -> Buffer
|
|
116
|
+
* @since 0.1.24
|
|
117
|
+
* @status experimental
|
|
118
|
+
* @spec RFC 8951, RFC 4648
|
|
119
|
+
* @related pki.est.transferEncode
|
|
120
|
+
*
|
|
121
|
+
* Decode an EST payload body (a base64 string or Buffer) to DER. CR/LF/space/tab
|
|
122
|
+
* are stripped anywhere (RFC 8951 sec. 3.1); any other non-alphabet byte fails
|
|
123
|
+
* closed with `est/bad-base64`. A Content-Transfer-Encoding header is NEVER read
|
|
124
|
+
* (errata 5904/5107). Bounded twice -- the raw length before decode and the
|
|
125
|
+
* decoded DER against `DER_MAX_BYTES` (`est/too-large`).
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* var roundTripped = pki.est.transferDecode(pki.est.transferEncode(der));
|
|
129
|
+
*/
|
|
130
|
+
function transferDecode(body) {
|
|
131
|
+
var s = Buffer.isBuffer(body) ? body.toString("latin1") : String(body);
|
|
132
|
+
// The pre-decode ceiling: the largest base64 that could yield a DER_MAX_BYTES
|
|
133
|
+
// document, plus a generous line-wrapping allowance (CRLF at 64/76-char lines is
|
|
134
|
+
// ~3%; 1/8 leaves ample margin) so a normally-wrapped near-limit body is not
|
|
135
|
+
// rejected before the real DER_MAX_BYTES limit is enforced on the decode below.
|
|
136
|
+
var b64Len = Math.ceil(constants.LIMITS.DER_MAX_BYTES * 4 / 3);
|
|
137
|
+
var cap = b64Len + Math.ceil(b64Len / 8) + constants.BYTES.kib(64);
|
|
138
|
+
if (s.length > cap) throw E("est/too-large", "the EST payload exceeds the transfer size cap");
|
|
139
|
+
var stripped = s.replace(/[\r\n \t]/g, "");
|
|
140
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(stripped)) throw E("est/bad-base64", "the EST payload is not RFC 4648 base64 (RFC 8951 sec. 3.1)");
|
|
141
|
+
var der = Buffer.from(stripped, "base64");
|
|
142
|
+
// Buffer.from silently truncates a non-canonical body (a length not a multiple
|
|
143
|
+
// of 4, e.g. "A", or non-zero trailing bits). Re-encode and require an exact
|
|
144
|
+
// round-trip so a malformed payload fails closed instead of decoding to a
|
|
145
|
+
// different, shorter DER (the PEM decoder applies the same canonical check).
|
|
146
|
+
if (der.toString("base64") !== stripped) throw E("est/bad-base64", "the EST payload is not canonical RFC 4648 base64 (RFC 8951 sec. 3.1)");
|
|
147
|
+
if (der.length > constants.LIMITS.DER_MAX_BYTES) throw E("est/too-large", "the decoded EST DER exceeds the size cap");
|
|
148
|
+
return der;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @primitive pki.est.transferEncode
|
|
153
|
+
* @signature pki.est.transferEncode(der) -> string
|
|
154
|
+
* @since 0.1.24
|
|
155
|
+
* @status experimental
|
|
156
|
+
* @spec RFC 8951, RFC 4648
|
|
157
|
+
* @related pki.est.transferDecode
|
|
158
|
+
*
|
|
159
|
+
* Encode DER as an EST payload body: bare RFC 4648 base64, no line wrapping
|
|
160
|
+
* (senders need not insert whitespace, RFC 8951 sec. 3.1).
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* var body = pki.est.transferEncode(der);
|
|
164
|
+
*/
|
|
165
|
+
function transferEncode(der) {
|
|
166
|
+
if (!Buffer.isBuffer(der)) throw E("est/bad-input", "transferEncode requires a DER Buffer");
|
|
167
|
+
return der.toString("base64");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- the multipart/mixed splitter (/serverkeygen) -----------------------
|
|
171
|
+
|
|
172
|
+
// Extract the boundary from a `multipart/mixed; boundary=...` content-type,
|
|
173
|
+
// tolerating whitespace before the semicolon (erratum 5779 REJECTED the ban).
|
|
174
|
+
function _multipartBoundary(contentType) {
|
|
175
|
+
var ct = String(contentType || "");
|
|
176
|
+
if (!/^multipart\/mixed\s*(;|$)/i.test(ct)) return null;
|
|
177
|
+
var m = /;\s*boundary\s*=\s*("([^"]+)"|([^;\s]+))/i.exec(ct);
|
|
178
|
+
return m ? (m[2] !== undefined ? m[2] : m[3]) : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Split a multipart/mixed body into its parts, each { headers, contentType,
|
|
182
|
+
// body }. A boundary delimiter is `--boundary` only at the START OF A LINE (body
|
|
183
|
+
// start or after CRLF), optional transport-padding, then CRLF (a part) or `--`
|
|
184
|
+
// (the close delimiter) -- matching the raw substring would treat `--boundaryX`,
|
|
185
|
+
// which is NOT a delimiter (RFC 2046), as one. The preamble/epilogue are ignored.
|
|
186
|
+
function splitMultipartMixed(body, contentType) {
|
|
187
|
+
var boundary = _multipartBoundary(contentType);
|
|
188
|
+
if (!boundary) throw E("est/bad-multipart", "a serverkeygen response must be multipart/mixed with a boundary (RFC 7030 sec. 4.4.2)");
|
|
189
|
+
var text = Buffer.isBuffer(body) ? body.toString("latin1") : String(body);
|
|
190
|
+
if (text.length > constants.LIMITS.DER_MAX_BYTES * 2) throw E("est/too-large", "the multipart body exceeds the size cap");
|
|
191
|
+
var esc = boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
192
|
+
var delimRe = new RegExp("(?:^|\\r?\\n)--" + esc + "(--)?[ \\t]*(?:\\r?\\n|$)", "g");
|
|
193
|
+
var marks = [], m;
|
|
194
|
+
while ((m = delimRe.exec(text)) !== null) {
|
|
195
|
+
marks.push({ at: m.index, bodyStart: delimRe.lastIndex, close: m[1] === "--" });
|
|
196
|
+
if (delimRe.lastIndex === m.index) delimRe.lastIndex += 1; // guard a zero-length match
|
|
197
|
+
}
|
|
198
|
+
var closeAt = -1;
|
|
199
|
+
for (var c = 0; c < marks.length; c++) { if (marks[c].close) { closeAt = c; break; } }
|
|
200
|
+
if (closeAt === -1) throw E("est/bad-multipart", "the multipart body is missing its terminal boundary (RFC 2046)");
|
|
201
|
+
var parts = [];
|
|
202
|
+
for (var i = 0; i < closeAt; i++) {
|
|
203
|
+
var seg = text.slice(marks[i].bodyStart, marks[i + 1].at); // between this delimiter and the next
|
|
204
|
+
var sep = seg.indexOf("\r\n\r\n");
|
|
205
|
+
if (sep === -1) sep = seg.indexOf("\n\n");
|
|
206
|
+
if (sep === -1) throw E("est/bad-multipart", "a multipart part is missing its header/body separator");
|
|
207
|
+
var rawHeaders = seg.slice(0, sep);
|
|
208
|
+
var partBody = seg.slice(sep).replace(/^(\r?\n){2}/, "").replace(/\r?\n$/, "");
|
|
209
|
+
var headers = {};
|
|
210
|
+
rawHeaders.split(/\r?\n/).forEach(function (line) {
|
|
211
|
+
var col = line.indexOf(":");
|
|
212
|
+
if (col > 0) headers[line.slice(0, col).trim().toLowerCase()] = line.slice(col + 1).trim();
|
|
213
|
+
});
|
|
214
|
+
var partCt = headers["content-type"] || "";
|
|
215
|
+
if (/^multipart\//i.test(partCt)) throw E("est/bad-multipart", "a nested multipart part is not permitted");
|
|
216
|
+
parts.push({ headers: headers, contentType: partCt, body: partBody });
|
|
217
|
+
}
|
|
218
|
+
return parts;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- the certs-only Simple PKI Response validator -----------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @primitive pki.est.parseCertsOnly
|
|
225
|
+
* @signature pki.est.parseCertsOnly(der) -> { certificates, crls }
|
|
226
|
+
* @since 0.1.24
|
|
227
|
+
* @status experimental
|
|
228
|
+
* @spec RFC 7030, RFC 5272, RFC 5652
|
|
229
|
+
* @related pki.est.findIssuedCert, pki.schema.cms.parse
|
|
230
|
+
*
|
|
231
|
+
* Validate a certs-only CMS Simple PKI Response (RFC 5272 sec. 4.1) over the
|
|
232
|
+
* shipped `cms.parse` output: a SignedData with no eContent and EMPTY
|
|
233
|
+
* signerInfos, carrying at least one plain X.509 certificate (a context-tagged
|
|
234
|
+
* CertificateChoices alternative is rejected `est/bad-certificate-choice`). CRLs
|
|
235
|
+
* MAY be present. Certificates come back RAW and in AS-RECEIVED order (never
|
|
236
|
+
* sorted -- RFC 5272 sec. 4.1). A non-conformant response throws a typed
|
|
237
|
+
* `EstError` (`est/not-certs-only`, `est/no-certificates`).
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* var r = pki.est.parseCertsOnly(caCertsDer);
|
|
241
|
+
* r.certificates; // -> [Buffer, ...] raw, unordered
|
|
242
|
+
*/
|
|
243
|
+
function parseCertsOnly(der) {
|
|
244
|
+
var r;
|
|
245
|
+
try { r = cms.parse(der); }
|
|
246
|
+
catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-response", "the EST response did not decode as CMS: " + ((e && e.message) || String(e)), e); }
|
|
247
|
+
if (r.contentTypeName !== "signedData") throw E("est/not-certs-only", "an EST certs-only response must be a CMS SignedData (RFC 5272 sec. 4.1)");
|
|
248
|
+
if (r.encapContentInfo.eContentType !== ID_DATA || r.encapContentInfo.eContent !== null) {
|
|
249
|
+
throw E("est/not-certs-only", "a certs-only Simple PKI Response must carry id-data with no eContent (RFC 5272 sec. 4.1)");
|
|
250
|
+
}
|
|
251
|
+
if (r.signerInfos.length !== 0) throw E("est/not-certs-only", "a certs-only Simple PKI Response must have empty signerInfos (RFC 5272 sec. 4.1)");
|
|
252
|
+
if (!r.certificates || r.certificates.length === 0) throw E("est/no-certificates", "an EST certs-only response must contain at least one certificate (RFC 7030 sec. 4.1.3)");
|
|
253
|
+
for (var i = 0; i < r.certificates.length; i++) {
|
|
254
|
+
if (r.certificates[i].tagClass !== "universal") throw E("est/bad-certificate-choice", "EST exchanges plain X.509 certificates; a tagged CertificateChoices alternative is not permitted (RFC 7030)");
|
|
255
|
+
// A universal-SEQUENCE CertificateChoice must be a well-formed X.509
|
|
256
|
+
// Certificate, not merely any SEQUENCE. Parse it structurally (still
|
|
257
|
+
// returning the raw bytes below) so a malformed response fails closed.
|
|
258
|
+
try { x509.parse(r.certificates[i].bytes); }
|
|
259
|
+
catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-certificate", "a certs-only response carried a non-certificate in its certificates field (RFC 5272 sec. 4.1)", e); }
|
|
260
|
+
}
|
|
261
|
+
var crls = r.crls || [];
|
|
262
|
+
for (var j = 0; j < crls.length; j++) {
|
|
263
|
+
// A RevocationInfoChoice is a plain X.509 CertificateList or a [1] otherRevInfo;
|
|
264
|
+
// EST surfaces CRLs, so reject the tagged alternative and structurally validate
|
|
265
|
+
// each universal entry as a CertificateList (mirrors the certificate path).
|
|
266
|
+
if (crls[j].tagClass !== "universal") throw E("est/bad-crl", "an EST response CRL must be a plain X.509 CertificateList, not a tagged otherRevInfo alternative (RFC 5652 sec. 10.2.1)");
|
|
267
|
+
try { crl.parse(crls[j].bytes); }
|
|
268
|
+
catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-crl", "a response carried a non-CRL in its crls field", e); }
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
certificates: r.certificates.map(function (c) { return c.bytes; }),
|
|
272
|
+
crls: crls.map(function (c) { return c.bytes; }),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Pick the issued certificate from a certs-only response by matching its public
|
|
277
|
+
// key against the CSR / SPKI the client submitted -- the ONLY sanctioned
|
|
278
|
+
// identification (positional guessing is forbidden, RFC 5272 sec. 4.1). `target`
|
|
279
|
+
// is an SPKI object (with a `bytes` subjectPublicKey field) or a raw Buffer.
|
|
280
|
+
function findIssuedCert(certs, target) {
|
|
281
|
+
var want = Buffer.isBuffer(target) ? target : (target && target.bytes);
|
|
282
|
+
if (!Buffer.isBuffer(want)) return null;
|
|
283
|
+
for (var i = 0; i < certs.length; i++) {
|
|
284
|
+
var spki;
|
|
285
|
+
try { spki = x509.parse(certs[i]).subjectPublicKeyInfo; }
|
|
286
|
+
catch (_e) { continue; }
|
|
287
|
+
if (spki && Buffer.isBuffer(spki.bytes) && spki.bytes.equals(want)) return certs[i];
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- /serverkeygen ------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
// Collect every key-identifier (subjectKeyIdentifier / KEKIdentifier) a set of
|
|
295
|
+
// RecipientInfos names, across the ktri / kari / kekri / kemri arms -- the
|
|
296
|
+
// byte identifiers a client's advertised decryptKeyID would match.
|
|
297
|
+
function _recipientKeyIds(recipientInfos) {
|
|
298
|
+
var ids = [];
|
|
299
|
+
function push(v) { if (Buffer.isBuffer(v)) ids.push(v); }
|
|
300
|
+
(recipientInfos || []).forEach(function (r) {
|
|
301
|
+
if (r.rid) push(r.rid.subjectKeyIdentifier);
|
|
302
|
+
if (r.kemri && r.kemri.rid) push(r.kemri.rid.subjectKeyIdentifier); // KEMRecipientInfo (RFC 9629, under the ori arm)
|
|
303
|
+
if (r.kekid) push(r.kekid.keyIdentifier);
|
|
304
|
+
(r.recipientEncryptedKeys || []).forEach(function (rek) { if (rek.rid) push(rek.rid.subjectKeyIdentifier); });
|
|
305
|
+
});
|
|
306
|
+
return ids;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The { issuer (raw DN bytes), serialNumber } every issuerAndSerialNumber recipient
|
|
310
|
+
// arm names -- the form a server MAY use after mapping an AsymmetricDecryptKeyIdentifier
|
|
311
|
+
// to a certificate (RFC 7030 sec. 4.4.2), so a key-id-only match would miss it.
|
|
312
|
+
function _recipientIssuerSerials(recipientInfos) {
|
|
313
|
+
var out = [];
|
|
314
|
+
function push(rid) { if (rid && rid.issuer && Buffer.isBuffer(rid.issuer.bytes) && rid.serialNumber != null) out.push({ issuer: rid.issuer.bytes, serialNumber: rid.serialNumber }); }
|
|
315
|
+
(recipientInfos || []).forEach(function (r) {
|
|
316
|
+
push(r.rid);
|
|
317
|
+
if (r.kemri) push(r.kemri.rid);
|
|
318
|
+
(r.recipientEncryptedKeys || []).forEach(function (rek) { push(rek.rid); });
|
|
319
|
+
});
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The advertised recipient (a decryptKeyID / asymmDecryptKeyID key identifier
|
|
324
|
+
// and/or an issuer+serial) matches SOME RecipientInfo arm -- either identifier
|
|
325
|
+
// form the server may have used to name the same requested key.
|
|
326
|
+
function _recipientMatches(recipientInfos, opts) {
|
|
327
|
+
if (Buffer.isBuffer(opts.expectedRecipientKeyId) &&
|
|
328
|
+
_recipientKeyIds(recipientInfos).some(function (id) { return id.equals(opts.expectedRecipientKeyId); })) return true;
|
|
329
|
+
var ias = opts.expectedRecipientIssuerSerial;
|
|
330
|
+
if (ias && Buffer.isBuffer(ias.issuer) && ias.serialNumber != null) {
|
|
331
|
+
var want = typeof ias.serialNumber === "bigint" ? ias.serialNumber : BigInt(ias.serialNumber);
|
|
332
|
+
if (_recipientIssuerSerials(recipientInfos).some(function (r) { return r.issuer.equals(ias.issuer) && r.serialNumber === want; })) return true;
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Parse a /serverkeygen response: exactly two parts -- a private-key part
|
|
338
|
+
// (application/pkcs8 cleartext PrivateKeyInfo, or application/pkcs7-mime;
|
|
339
|
+
// smime-type=server-generated-key EnvelopedData) and a certificate part (the
|
|
340
|
+
// enroll-response shape). opts.requestedEncryption asserts the key part is
|
|
341
|
+
// encrypted (a cleartext part -> est/expected-encrypted-key). The advertised
|
|
342
|
+
// recipient may be given as opts.expectedRecipientKeyId (a Buffer, the
|
|
343
|
+
// decryptKeyID / asymmDecryptKeyID) and/or opts.expectedRecipientIssuerSerial
|
|
344
|
+
// ({ issuer: raw-DN Buffer, serialNumber }, the form a server MAY use after
|
|
345
|
+
// mapping that identifier to a certificate); when either is given, a RecipientInfo
|
|
346
|
+
// of the encrypted key must match one of them (else est/recipient-mismatch) -- so
|
|
347
|
+
// a response encrypted to a DIFFERENT recipient fails closed rather than passing.
|
|
348
|
+
// Split a content-type on `;` that are OUTSIDE a quoted-string (RFC 2045),
|
|
349
|
+
// so a `;` inside a parameter value does not create a spurious parameter.
|
|
350
|
+
function _splitContentTypeParams(s) {
|
|
351
|
+
var segs = [], cur = "", inQuote = false;
|
|
352
|
+
for (var i = 0; i < s.length; i++) {
|
|
353
|
+
var ch = s[i];
|
|
354
|
+
if (ch === '"') { inQuote = !inQuote; cur += ch; }
|
|
355
|
+
else if (ch === ";" && !inQuote) { segs.push(cur); cur = ""; }
|
|
356
|
+
else cur += ch;
|
|
357
|
+
}
|
|
358
|
+
segs.push(cur);
|
|
359
|
+
return segs;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Split a part's content-type into its base media type (lowercased) and its
|
|
363
|
+
// smime-type parameter. The media type must be the EXACT token (a look-alike
|
|
364
|
+
// like application/pkcs8evil yields that literal, not application/pkcs8), and
|
|
365
|
+
// parameters are read token-by-token honoring quoted-strings so a smime-type-like
|
|
366
|
+
// substring inside another quoted parameter value is NOT taken as smime-type.
|
|
367
|
+
function _partMediaType(contentType) {
|
|
368
|
+
var segs = _splitContentTypeParams(String(contentType || ""));
|
|
369
|
+
var mediaMatch = /^\s*([a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*)\s*$/i.exec(segs[0]);
|
|
370
|
+
var smimeType = null;
|
|
371
|
+
for (var i = 1; i < segs.length; i++) {
|
|
372
|
+
var eq = segs[i].indexOf("=");
|
|
373
|
+
if (eq === -1) continue;
|
|
374
|
+
if (segs[i].slice(0, eq).trim().toLowerCase() !== "smime-type") continue;
|
|
375
|
+
var val = segs[i].slice(eq + 1).trim();
|
|
376
|
+
if (val.length >= 2 && val.charAt(0) === '"' && val.charAt(val.length - 1) === '"') val = val.slice(1, -1);
|
|
377
|
+
smimeType = val.toLowerCase();
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
return { media: mediaMatch ? mediaMatch[1].toLowerCase() : null, smimeType: smimeType };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function parseServerKeygenResponse(body, contentType, opts) {
|
|
384
|
+
opts = opts || {};
|
|
385
|
+
var parts = splitMultipartMixed(body, contentType);
|
|
386
|
+
if (parts.length !== 2) throw E("est/bad-multipart", "a serverkeygen response must have exactly two parts (RFC 7030 sec. 4.4.2)");
|
|
387
|
+
var keyPart = null, certPart = null, encrypted = false;
|
|
388
|
+
for (var i = 0; i < parts.length; i++) {
|
|
389
|
+
var pt = _partMediaType(parts[i].contentType);
|
|
390
|
+
if (pt.media === "application/pkcs8") { keyPart = parts[i]; encrypted = false; }
|
|
391
|
+
else if (pt.media === "application/pkcs7-mime" && pt.smimeType === "server-generated-key") { keyPart = parts[i]; encrypted = true; }
|
|
392
|
+
// The certificate part exactly matches the /simpleenroll response
|
|
393
|
+
// (RFC 7030 sec. 4.4.2), so it too MUST be smime-type=certs-only.
|
|
394
|
+
else if (pt.media === "application/pkcs7-mime" && pt.smimeType === "certs-only") certPart = parts[i];
|
|
395
|
+
else throw E("est/bad-multipart", "unrecognized serverkeygen part content-type " + JSON.stringify(parts[i].contentType));
|
|
396
|
+
}
|
|
397
|
+
if (!keyPart || !certPart) throw E("est/bad-multipart", "a serverkeygen response needs one key part and one certificate part");
|
|
398
|
+
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)");
|
|
399
|
+
var out = { certificates: parseCertsOnly(transferDecode(certPart.body)).certificates };
|
|
400
|
+
if (encrypted) {
|
|
401
|
+
// The server-generated key MUST be a CMS EnvelopedData (RFC 7030 sec. 4.4.2);
|
|
402
|
+
// a SignedData or any other CMS content type under the encrypted label is a
|
|
403
|
+
// structurally invalid key part, not a success. The EnvelopedData is surfaced
|
|
404
|
+
// structurally -- the ciphertext stays opaque and the RecipientInfo arms feed
|
|
405
|
+
// the coherence check the caller makes.
|
|
406
|
+
var parsedKey = cms.parse(transferDecode(keyPart.body));
|
|
407
|
+
if (parsedKey.contentTypeName !== "envelopedData") throw E("est/bad-key-part", "a server-generated encrypted key part must be a CMS EnvelopedData (RFC 7030 sec. 4.4.2), got " + JSON.stringify(parsedKey.contentTypeName));
|
|
408
|
+
// The EnvelopedData MUST encapsulate a CMS SignedData holding the private key
|
|
409
|
+
// (RFC 7030 sec. 4.4.2) -- validate the inner encryptedContentInfo content type,
|
|
410
|
+
// not just the outer ContentInfo, so a key wrapped around id-data or anything
|
|
411
|
+
// else fails closed before the caller decrypts it.
|
|
412
|
+
if (!parsedKey.encryptedContentInfo || parsedKey.encryptedContentInfo.contentType !== ID_SIGNED_DATA) throw E("est/bad-key-part", "a server-generated encrypted key's EnvelopedData must encapsulate a CMS SignedData (RFC 7030 sec. 4.4.2)");
|
|
413
|
+
// A detached EnvelopedData (encryptedContent omitted) carries no ciphertext to
|
|
414
|
+
// decrypt -- there is no key to recover, so it is not a valid key response.
|
|
415
|
+
if (parsedKey.encryptedContentInfo.encryptedContent === null) throw E("est/bad-key-part", "a server-generated encrypted key's EnvelopedData must carry its encrypted content, not be detached (RFC 7030 sec. 4.4.2)");
|
|
416
|
+
if (Buffer.isBuffer(opts.expectedRecipientKeyId) || opts.expectedRecipientIssuerSerial) {
|
|
417
|
+
if (!_recipientMatches(parsedKey.recipientInfos, opts)) throw E("est/recipient-mismatch", "the server-generated key is not encrypted to the advertised recipient (RFC 7030 sec. 4.4.2)");
|
|
418
|
+
}
|
|
419
|
+
out.encryptedKey = parsedKey;
|
|
420
|
+
} else {
|
|
421
|
+
out.privateKey = pkcs8.parse(transferDecode(keyPart.body));
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- the HTTP response classifier --------------------------------------
|
|
427
|
+
|
|
428
|
+
// The required 200-response content-type per operation: the EXACT media-type
|
|
429
|
+
// token plus, where the RFC mandates it, the smime-type parameter. simpleenroll /
|
|
430
|
+
// simplereenroll require smime-type=certs-only (RFC 7030 sec. 4.2.3) so a different
|
|
431
|
+
// PKI message type (CMC-response, ...) is not accepted; cacerts mandates only the
|
|
432
|
+
// media type (sec. 4.1.3). Matched token-wise, not by prefix, so a look-alike like
|
|
433
|
+
// "application/pkcs7-mimeevil" is rejected.
|
|
434
|
+
var CONTENT_TYPE_BY_OP = {
|
|
435
|
+
cacerts: { media: "application/pkcs7-mime" },
|
|
436
|
+
simpleenroll: { media: "application/pkcs7-mime", smimeType: "certs-only" },
|
|
437
|
+
simplereenroll: { media: "application/pkcs7-mime", smimeType: "certs-only" },
|
|
438
|
+
serverkeygen: { media: "multipart/mixed" },
|
|
439
|
+
csrattrs: { media: "application/csrattrs" },
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// The operations whose responses this client classifies. /fullcmc is a real EST
|
|
443
|
+
// path (paths() emits its URL) but its CMC response is deferred to the CMC
|
|
444
|
+
// module, so classifying one is an explicit unsupported-operation fault rather
|
|
445
|
+
// than a silently-accepted 200 whose content-type went unchecked.
|
|
446
|
+
var CLASSIFIABLE_OPS = ["cacerts", "simpleenroll", "simplereenroll", "serverkeygen", "csrattrs"];
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* @primitive pki.est.classifyResponse
|
|
450
|
+
* @signature pki.est.classifyResponse(status, headers, body, opts?) -> verdict
|
|
451
|
+
* @since 0.1.24
|
|
452
|
+
* @status experimental
|
|
453
|
+
* @spec RFC 7030, RFC 8951
|
|
454
|
+
* @related pki.est.paths, pki.est.parseCertsOnly
|
|
455
|
+
*
|
|
456
|
+
* Classify an EST HTTP response into a verdict or a typed fault. A 200 requires
|
|
457
|
+
* the operation's exact content-type (`est/bad-content-type`); a 202 requires a
|
|
458
|
+
* Retry-After (absent -> `est/missing-retry-after`) -- a delay-seconds value is
|
|
459
|
+
* surfaced as bounded `retryAfterSeconds`, an HTTP-date as absolute
|
|
460
|
+
* `retryAfterDate` (epoch ms; `retryAfterSeconds` too when `opts.now` is given),
|
|
461
|
+
* and any other value is `est/bad-retry-after` (never slept on either way);
|
|
462
|
+
* 204/404 on `/csrattrs` is a `none-available` verdict (an error on any other
|
|
463
|
+
* operation); 4xx/5xx surface the capped diagnostic on `est/http-error`.
|
|
464
|
+
*
|
|
465
|
+
* @opts
|
|
466
|
+
* op: string // the EST operation this response answers
|
|
467
|
+
* now: number // the response receipt time (epoch ms), to turn an HTTP-date Retry-After into retryAfterSeconds
|
|
468
|
+
*
|
|
469
|
+
* @example
|
|
470
|
+
* var v = pki.est.classifyResponse(202, { "retry-after": "120" }, "", { op: "simpleenroll" });
|
|
471
|
+
* v.retryAfterSeconds; // -> 120
|
|
472
|
+
*/
|
|
473
|
+
function classifyResponse(status, headers, body, opts) {
|
|
474
|
+
opts = opts || {};
|
|
475
|
+
var op = opts.op;
|
|
476
|
+
// Fail closed on an operation whose response this client cannot validate:
|
|
477
|
+
// /fullcmc is recognized-but-deferred, and any other named op is a typo. An
|
|
478
|
+
// absent op is the caller opting out of the content-type gate (generic
|
|
479
|
+
// status handling) and stays permissive.
|
|
480
|
+
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)");
|
|
481
|
+
if (op !== undefined && op !== null && CLASSIFIABLE_OPS.indexOf(op) === -1) throw E("est/unsupported-operation", "unrecognized EST operation " + JSON.stringify(op));
|
|
482
|
+
var h = {};
|
|
483
|
+
Object.keys(headers || {}).forEach(function (k) { h[k.toLowerCase()] = headers[k]; });
|
|
484
|
+
if (status === 200) {
|
|
485
|
+
var spec = CONTENT_TYPE_BY_OP[op];
|
|
486
|
+
var ct = h["content-type"] || "";
|
|
487
|
+
if (spec) {
|
|
488
|
+
var pt = _partMediaType(ct);
|
|
489
|
+
if (pt.media !== spec.media || (spec.smimeType && pt.smimeType !== spec.smimeType)) {
|
|
490
|
+
throw E("est/bad-content-type", "a 200 " + op + " response must carry content-type " + spec.media + (spec.smimeType ? "; smime-type=" + spec.smimeType : "") + ", got " + JSON.stringify(ct));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return { status: "ok", contentType: ct };
|
|
494
|
+
}
|
|
495
|
+
if (status === 202) {
|
|
496
|
+
var ra = h["retry-after"];
|
|
497
|
+
if (ra === undefined || ra === null || String(ra).trim() === "") throw E("est/missing-retry-after", "an HTTP 202 EST response must include Retry-After (RFC 8951 sec. 3.3)");
|
|
498
|
+
var raStr = String(ra).trim();
|
|
499
|
+
// Retry-After is delay-seconds OR an HTTP-date (RFC 7231 sec. 7.1.3). A
|
|
500
|
+
// delay-seconds becomes retryAfterSeconds directly; an HTTP-date is surfaced
|
|
501
|
+
// as an absolute retryAfterDate (epoch ms) -- and, when the caller passes its
|
|
502
|
+
// receipt time as opts.now (epoch ms), also as a bounded retryAfterSeconds.
|
|
503
|
+
// Neither form -> fail closed rather than a retry verdict with no delay.
|
|
504
|
+
var out202 = { status: "retry", retryAfter: raStr, retryAfterSeconds: null, retryAfterDate: null };
|
|
505
|
+
if (/^\d+$/.test(raStr)) {
|
|
506
|
+
var n = parseInt(raStr, 10);
|
|
507
|
+
if (!Number.isSafeInteger(n) || n > MAX_RETRY_AFTER_SECONDS) throw E("est/bad-retry-after", "the 202 Retry-After delay is out of the supported range (0.." + MAX_RETRY_AFTER_SECONDS + " seconds)");
|
|
508
|
+
out202.retryAfterSeconds = n;
|
|
509
|
+
} else {
|
|
510
|
+
var when = _httpDateMs(raStr);
|
|
511
|
+
if (isNaN(when)) throw E("est/bad-retry-after", "a 202 Retry-After must be delay-seconds or a valid HTTP-date (RFC 7231 sec. 7.1.1.1/7.1.3), got " + JSON.stringify(raStr));
|
|
512
|
+
out202.retryAfterDate = when;
|
|
513
|
+
if (typeof opts.now === "number" && isFinite(opts.now)) {
|
|
514
|
+
var d = Math.max(0, Math.round((when - opts.now) / constants.TIME.seconds(1)));
|
|
515
|
+
// The same one-year ceiling the delay-seconds form enforces (a date far in
|
|
516
|
+
// the future would otherwise surface a huge numeric delay).
|
|
517
|
+
if (d > MAX_RETRY_AFTER_SECONDS) throw E("est/bad-retry-after", "the 202 Retry-After date is beyond the supported horizon (" + MAX_RETRY_AFTER_SECONDS + " seconds)");
|
|
518
|
+
out202.retryAfterSeconds = d;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return out202;
|
|
522
|
+
}
|
|
523
|
+
if (status === 204 || status === 404) {
|
|
524
|
+
if (op === "csrattrs") return { status: "none-available" };
|
|
525
|
+
throw E("est/http-error", "HTTP " + status + " is not a valid " + op + " response");
|
|
526
|
+
}
|
|
527
|
+
if (status >= 300 && status < 400) return { status: "redirect", location: h["location"] || null };
|
|
528
|
+
if (status >= 400) {
|
|
529
|
+
var text = Buffer.isBuffer(body) ? body.toString("utf8") : String(body || "");
|
|
530
|
+
throw E("est/http-error", "EST server returned HTTP " + status + (text ? ": " + text.slice(0, 512) : ""));
|
|
531
|
+
}
|
|
532
|
+
return { status: "unexpected", httpStatus: status };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---- operation-path builder ---------------------------------------------
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* @primitive pki.est.paths
|
|
539
|
+
* @signature pki.est.paths(baseUrl, opts?) -> { cacerts, simpleenroll, ... }
|
|
540
|
+
* @since 0.1.24
|
|
541
|
+
* @status experimental
|
|
542
|
+
* @spec RFC 7030
|
|
543
|
+
* @related pki.est.classifyResponse
|
|
544
|
+
*
|
|
545
|
+
* Build the RFC 7030 sec. 3.2.2 operation URLs for a base server URL. An OPTIONAL
|
|
546
|
+
* CA label (`opts.label`) MUST be non-empty, carry no `/`, and not collide with
|
|
547
|
+
* an operation name, else `est/bad-label`.
|
|
548
|
+
*
|
|
549
|
+
* @opts
|
|
550
|
+
* label: string // an OPTIONAL CA label path segment
|
|
551
|
+
*
|
|
552
|
+
* @example
|
|
553
|
+
* pki.est.paths("https://ca.example").cacerts;
|
|
554
|
+
* // -> "https://ca.example/.well-known/est/cacerts"
|
|
555
|
+
*/
|
|
556
|
+
function paths(baseUrl, opts) {
|
|
557
|
+
opts = opts || {};
|
|
558
|
+
var prefix = String(baseUrl).replace(/\/+$/, "") + "/.well-known/est";
|
|
559
|
+
if (opts.label != null) {
|
|
560
|
+
var label = String(opts.label);
|
|
561
|
+
// A label is ONE URL path segment: unreserved characters only (RFC 3986),
|
|
562
|
+
// never a dot-segment or an operation name. Rejecting rather than
|
|
563
|
+
// percent-encoding keeps a reserved char (`/` `?` `#` `%`) or `..` from
|
|
564
|
+
// silently retargeting the request to a different resource.
|
|
565
|
+
if (label === "" || label === "." || label === ".." || !/^[A-Za-z0-9._~-]+$/.test(label) || OPERATIONS.indexOf(label) !== -1) {
|
|
566
|
+
throw E("est/bad-label", "an EST CA label must be a single path segment of unreserved characters, not '.' / '..' or an operation name (RFC 7030 sec. 3.2.2)");
|
|
567
|
+
}
|
|
568
|
+
prefix += "/" + label;
|
|
569
|
+
}
|
|
570
|
+
var out = {};
|
|
571
|
+
OPERATIONS.forEach(function (op) { out[op] = prefix + "/" + op; });
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ---- builders: the CSR attributes EST adds ------------------------------
|
|
576
|
+
|
|
577
|
+
function _attr(typeOid, valueNodes) { return asn1.build.sequence([asn1.build.oid(typeOid), asn1.build.set(valueNodes)]); }
|
|
578
|
+
|
|
579
|
+
// the RFC 5929 tls-unique channel-binding bytes -> a challengePassword
|
|
580
|
+
// attribute whose value is their RFC 4648 base64 (SIZE 1..255). The builder
|
|
581
|
+
// takes caller-supplied binding bytes and never fakes one.
|
|
582
|
+
function challengePasswordFromTlsUnique(channelBinding) {
|
|
583
|
+
if (!Buffer.isBuffer(channelBinding) || channelBinding.length === 0) throw E("est/bad-input", "challengePasswordFromTlsUnique requires the channel-binding bytes");
|
|
584
|
+
var b64 = channelBinding.toString("base64");
|
|
585
|
+
if (b64.length > 255) throw E("est/tls-unique-too-long", "the base64 tls-unique value exceeds 255 octets (RFC 7030 sec. 3.5)");
|
|
586
|
+
return _attr(OID_CHALLENGE_PASSWORD, [asn1.build.printable(b64)]);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// the out-of-band key-encryption-key identifiers (OCTET STRING values).
|
|
590
|
+
function decryptKeyIdentifierAttr(keyId) {
|
|
591
|
+
if (!Buffer.isBuffer(keyId)) throw E("est/bad-input", "decryptKeyIdentifierAttr requires the key-identifier bytes");
|
|
592
|
+
return _attr(OID_DECRYPT_KEY_ID, [asn1.build.octetString(keyId)]);
|
|
593
|
+
}
|
|
594
|
+
function asymmetricDecryptKeyIdentifierAttr(keyId) {
|
|
595
|
+
if (!Buffer.isBuffer(keyId)) throw E("est/bad-input", "asymmetricDecryptKeyIdentifierAttr requires the key-identifier bytes");
|
|
596
|
+
return _attr(OID_ASYMM_DECRYPT_KEY_ID, [asn1.build.octetString(keyId)]);
|
|
597
|
+
}
|
|
598
|
+
// SMIMECapabilities ::= SEQUENCE OF SMIMECapability { capabilityID OID,
|
|
599
|
+
// parameters ANY OPTIONAL }.
|
|
600
|
+
function smimeCapabilitiesAttr(capabilities) {
|
|
601
|
+
if (!Array.isArray(capabilities) || capabilities.length === 0) throw E("est/bad-input", "smimeCapabilitiesAttr requires a non-empty capability list");
|
|
602
|
+
var caps = capabilities.map(function (c) {
|
|
603
|
+
var seq = [asn1.build.oid(c.capabilityID)];
|
|
604
|
+
if (c.parameters !== undefined && c.parameters !== null) seq.push(Buffer.isBuffer(c.parameters) ? c.parameters : asn1.build.oid(c.parameters));
|
|
605
|
+
return asn1.build.sequence(seq);
|
|
606
|
+
});
|
|
607
|
+
return _attr(OID_SMIME_CAPABILITIES, [asn1.build.sequence(caps)]);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Derive the enroll-request attribute plan from a parsed CsrAttrs.
|
|
611
|
+
// When a template is present the plan derives from the TEMPLATE ONLY and ignores
|
|
612
|
+
// every other element (RFC 9908 sec. 4). The challengePassword OID is an
|
|
613
|
+
// INSTRUCTION ("include tls-unique") -> a channelBindingRequired flag, never a
|
|
614
|
+
// password value (the RFC 7030 example that echoed it is "NOT CORRECT",
|
|
615
|
+
// RFC 9908 sec. 4). Every item the plan does not model into a specific field --
|
|
616
|
+
// including a REGISTERED-but-unmodeled instruction like a bare signature-algorithm
|
|
617
|
+
// OID (RFC 8951) -- is surfaced on `unhandled` ({ kind, oid, name }) so the client
|
|
618
|
+
// never silently drops a server requirement.
|
|
619
|
+
function buildEnrollAttributes(csrattrsParsed) {
|
|
620
|
+
var items = (csrattrsParsed && csrattrsParsed.items) || [];
|
|
621
|
+
var template = null;
|
|
622
|
+
for (var i = 0; i < items.length; i++) {
|
|
623
|
+
if (items[i].kind === "attribute" && items[i].oid === OID_TEMPLATE) { template = items[i].template; break; }
|
|
624
|
+
}
|
|
625
|
+
if (template) return { fromTemplate: true, template: template, channelBindingRequired: false, unhandled: [] };
|
|
626
|
+
var plan = { fromTemplate: false, channelBindingRequired: false, keyType: null, extensions: null, unhandled: [] };
|
|
627
|
+
for (var j = 0; j < items.length; j++) {
|
|
628
|
+
var it = items[j];
|
|
629
|
+
if (it.oid === OID_CHALLENGE_PASSWORD) plan.channelBindingRequired = true;
|
|
630
|
+
else if (it.kind === "attribute" && it.extensions) plan.extensions = it.extensions;
|
|
631
|
+
else if (it.kind === "attribute" && it.isKeyType) {
|
|
632
|
+
// EVERY key type the parser accepts is a key-type constraint (RSA / EC /
|
|
633
|
+
// Ed25519 / ML-DSA / ...). The non-template form carries EXACTLY ONE key-type
|
|
634
|
+
// attribute (RFC 9908 sec. 3.2); a second is an ambiguous server instruction,
|
|
635
|
+
// not a last-one-wins override -- fail closed rather than pick one by order.
|
|
636
|
+
if (plan.keyType) throw E("est/ambiguous-key-type", "a non-template CsrAttrs response must carry exactly one key-type attribute (RFC 9908 sec. 3.2)");
|
|
637
|
+
// Surface the raw values too: a key type this planner does not decode into
|
|
638
|
+
// curve / keySize (Ed25519, ML-DSA, ...) may still carry RFC 9908 parameters,
|
|
639
|
+
// so never silently drop them.
|
|
640
|
+
plan.keyType = { type: it.name, curve: it.curve || null, keySize: it.keySize || null, values: it.values || [] };
|
|
641
|
+
}
|
|
642
|
+
else plan.unhandled.push({ kind: it.kind, oid: it.oid, name: it.name });
|
|
643
|
+
}
|
|
644
|
+
return plan;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
var SAN_OID = oid.byName("subjectAltName");
|
|
648
|
+
|
|
649
|
+
// Pull the SubjectAltName extension's raw extnValue (the DER GeneralNames) off a
|
|
650
|
+
// parsed extension list -- a certificate's `.extensions` or a CSR's
|
|
651
|
+
// extensionRequest `.extensions` -- as { critical, value }, or null when no SAN
|
|
652
|
+
// is present. Both fields matter: "identical" (RFC 7030 sec. 4.2.2) covers the
|
|
653
|
+
// criticality flag, not just the GeneralNames bytes.
|
|
654
|
+
function _san(extList) {
|
|
655
|
+
if (!Array.isArray(extList)) return null;
|
|
656
|
+
for (var i = 0; i < extList.length; i++) {
|
|
657
|
+
if (extList[i].oid === SAN_OID) return { critical: !!extList[i].critical, value: extList[i].value };
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// The requested extensions a CSR carries in its extensionRequest attribute
|
|
663
|
+
// (RFC 2985 sec. 5.4.2), decoded by the CSR parser, or null when absent. A CSR
|
|
664
|
+
// carrying more than one extensionRequest is structurally legal (no SET-OF
|
|
665
|
+
// uniqueness) but AMBIGUOUS for the re-enroll SAN comparison -- fail closed
|
|
666
|
+
// rather than trusting the DER-first one while a later one requests a different SAN.
|
|
667
|
+
function _csrRequestedExtensions(parsedCsr) {
|
|
668
|
+
var attrs = (parsedCsr && parsedCsr.attributes) || [];
|
|
669
|
+
var extReqOid = oid.byName("extensionRequest");
|
|
670
|
+
var found = null, count = 0;
|
|
671
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
672
|
+
if (attrs[i].type === extReqOid) { count += 1; found = attrs[i].extensions || null; }
|
|
673
|
+
}
|
|
674
|
+
if (count > 1) throw E("est/reenroll-ambiguous-request", "a re-enroll CSR must not carry more than one extensionRequest attribute (RFC 7030 sec. 4.2.2)");
|
|
675
|
+
return found;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// A re-enroll CSR MUST reuse the old certificate's Subject and SubjectAltName
|
|
679
|
+
// extension byte-identically (RFC 7030 sec. 4.2.2). Returns the old cert's
|
|
680
|
+
// subject + SAN for verbatim reuse in the new CSR; on a caller-supplied new CSR,
|
|
681
|
+
// compares both by raw DER (a rendered DN two different string encodings can
|
|
682
|
+
// share is NOT enough) and throws est/reenroll-subject-mismatch or
|
|
683
|
+
// est/reenroll-san-mismatch on any divergence (a SAN present on one side and
|
|
684
|
+
// absent on the other is a mismatch, not an accept).
|
|
685
|
+
function reenrollGuard(oldCertDer, newCsrDer) {
|
|
686
|
+
var oldCert = x509.parse(oldCertDer);
|
|
687
|
+
var oldSubject = oldCert.subject.rdns; // the parsed subject; raw region surfaced below
|
|
688
|
+
var oldSubjectDn = oldCert.subject.dn;
|
|
689
|
+
var oldSubjectBytes = oldCert.subject.bytes;
|
|
690
|
+
var oldSan = _san(oldCert.extensions);
|
|
691
|
+
if (newCsrDer === undefined) return { subjectDn: oldSubjectDn, subject: oldSubject, subjectAltName: oldSan };
|
|
692
|
+
var parsedCsr = csr.parse(newCsrDer);
|
|
693
|
+
var subjectMatches = Buffer.isBuffer(oldSubjectBytes) && Buffer.isBuffer(parsedCsr.subject.bytes) && oldSubjectBytes.equals(parsedCsr.subject.bytes);
|
|
694
|
+
if (!subjectMatches) throw E("est/reenroll-subject-mismatch", "a re-enroll CSR subject must be byte-identical to the certificate being renewed (RFC 7030 sec. 4.2.2)");
|
|
695
|
+
var newSan = _san(_csrRequestedExtensions(parsedCsr));
|
|
696
|
+
var sanMatches = (oldSan === null && newSan === null) ||
|
|
697
|
+
(oldSan && newSan && oldSan.critical === newSan.critical && Buffer.isBuffer(oldSan.value) && Buffer.isBuffer(newSan.value) && oldSan.value.equals(newSan.value));
|
|
698
|
+
if (!sanMatches) throw E("est/reenroll-san-mismatch", "a re-enroll CSR subjectAltName (names and criticality) must be identical to the certificate being renewed (RFC 7030 sec. 4.2.2)");
|
|
699
|
+
return { subjectDn: oldSubjectDn, subjectAltName: oldSan };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
module.exports = {
|
|
703
|
+
transferDecode: transferDecode,
|
|
704
|
+
transferEncode: transferEncode,
|
|
705
|
+
splitMultipartMixed: splitMultipartMixed,
|
|
706
|
+
parseCertsOnly: parseCertsOnly,
|
|
707
|
+
findIssuedCert: findIssuedCert,
|
|
708
|
+
parseServerKeygenResponse: parseServerKeygenResponse,
|
|
709
|
+
classifyResponse: classifyResponse,
|
|
710
|
+
paths: paths,
|
|
711
|
+
challengePasswordFromTlsUnique: challengePasswordFromTlsUnique,
|
|
712
|
+
decryptKeyIdentifierAttr: decryptKeyIdentifierAttr,
|
|
713
|
+
asymmetricDecryptKeyIdentifierAttr: asymmetricDecryptKeyIdentifierAttr,
|
|
714
|
+
smimeCapabilitiesAttr: smimeCapabilitiesAttr,
|
|
715
|
+
buildEnrollAttributes: buildEnrollAttributes,
|
|
716
|
+
reenrollGuard: reenrollGuard,
|
|
717
|
+
};
|