@blamejs/pki 0.1.24 → 0.1.26
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 +27 -0
- package/README.md +4 -1
- package/index.js +12 -0
- package/lib/acme.js +1159 -0
- package/lib/constants.js +10 -0
- package/lib/framework-error.js +18 -0
- package/lib/jose.js +616 -0
- package/lib/oid.js +2 -1
- package/lib/schema-pkix.js +33 -0
- package/package.json +3 -1
- package/sbom.cdx.json +6 -6
package/lib/acme.js
ADDED
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.acme
|
|
6
|
+
* @nav Protocols
|
|
7
|
+
* @title ACME
|
|
8
|
+
* @order 20
|
|
9
|
+
* @slug acme
|
|
10
|
+
*
|
|
11
|
+
* @intro
|
|
12
|
+
* The RFC 8555 ACME message layer (updated by RFC 8737 tls-alpn-01, RFC 8738
|
|
13
|
+
* IP identifiers, and RFC 9773 ARI) -- object validators, request builders,
|
|
14
|
+
* challenge computations, and the ARI certID codec over the `pki.jose` JWS
|
|
15
|
+
* envelope. This is a MESSAGE LAYER, not an HTTP client: it owns the JWS
|
|
16
|
+
* construction/verification, the resource-object validation (closed status
|
|
17
|
+
* enums, conditional-required fields, immutable arrays), the three RFC 8555
|
|
18
|
+
* sec. 7.1.6 state machines, the challenge computations (key authorization,
|
|
19
|
+
* http-01, dns-01, tls-alpn-01), the identifier validators (`dns` / `ip` /
|
|
20
|
+
* wildcard), and the ARI certID -- over an injectable transport.
|
|
21
|
+
*
|
|
22
|
+
* Every resource object is validated by a declarative spec table (the JSON
|
|
23
|
+
* analog of the ASN.1 schema engine): one definition per surface drives both
|
|
24
|
+
* `validate(obj)` and the builders. Unknown fields are tolerated (ignored,
|
|
25
|
+
* never reflected); unknown challenge types are surfaced raw. Where ACME output
|
|
26
|
+
* re-enters the DER world -- the finalize CSR, the downloaded certificate
|
|
27
|
+
* chain, the revokeCert payload, the ARI inputs -- it routes through the shipped
|
|
28
|
+
* `pki.schema.csr` / `pki.schema.x509` parsers, so no new DER detector appears
|
|
29
|
+
* and the format-orchestrator's mutual-exclusion proof is untouched.
|
|
30
|
+
*
|
|
31
|
+
* @card
|
|
32
|
+
* RFC 8555 / 8737 / 8738 / 9773 ACME message layer: object validators, the
|
|
33
|
+
* three state machines, request builders, http-01 / dns-01 / tls-alpn-01
|
|
34
|
+
* challenge computations, and the ARI certID -- over pki.jose, transport-injectable.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
var jose = require("./jose");
|
|
38
|
+
var asn1 = require("./asn1-der");
|
|
39
|
+
var oid = require("./oid");
|
|
40
|
+
var x509 = require("./schema-x509");
|
|
41
|
+
var csr = require("./schema-csr");
|
|
42
|
+
var pkix = require("./schema-pkix");
|
|
43
|
+
var constants = require("./constants");
|
|
44
|
+
var subtle = require("./webcrypto").webcrypto.subtle;
|
|
45
|
+
var frameworkError = require("./framework-error");
|
|
46
|
+
|
|
47
|
+
var AcmeError = frameworkError.AcmeError;
|
|
48
|
+
function E(code, message, cause) { return new AcmeError(code, message, cause); }
|
|
49
|
+
|
|
50
|
+
// ---- helpers -------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
function _isObject(v) { return v && typeof v === "object" && !Array.isArray(v); }
|
|
53
|
+
function _isString(v) { return typeof v === "string"; }
|
|
54
|
+
// RFC 3339 date-time (date "T" time with a zone). Beyond the grammar this checks
|
|
55
|
+
// CALENDAR validity -- a syntactically well-formed but impossible instant (month 13,
|
|
56
|
+
// February 30, hour 25, a +25:00 offset) is rejected, so a downstream expiry / window
|
|
57
|
+
// comparison never runs on a value JS Date would silently roll over.
|
|
58
|
+
var RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/;
|
|
59
|
+
function _isRfc3339(v) {
|
|
60
|
+
if (!_isString(v)) return false;
|
|
61
|
+
var m = RFC3339_RE.exec(v);
|
|
62
|
+
if (!m) return false;
|
|
63
|
+
var year = +m[1], month = +m[2], day = +m[3], hour = +m[4], min = +m[5], sec = +m[6];
|
|
64
|
+
if (month < 1 || month > 12) return false;
|
|
65
|
+
// Reject a :60 leap second: Node's Date.parse returns NaN for it, so an expiry /
|
|
66
|
+
// renewal-window comparison on such a value would silently pass (NaN <= x is
|
|
67
|
+
// false). This toolkit only handles instants it can compare, so it fails closed.
|
|
68
|
+
if (hour > 23 || min > 59 || sec > 59) return false;
|
|
69
|
+
var leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
70
|
+
var daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
71
|
+
if (day < 1 || day > daysInMonth[month - 1]) return false;
|
|
72
|
+
if (m[7]) { if (+m[8] > 23 || +m[9] > 59) return false; } // numeric zone offset
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
// A URL string: an absolute http(s) URI with a real host (RFC 3986). ACME URLs are
|
|
76
|
+
// server-provided endpoints downstream transport will trust, so they are PARSED
|
|
77
|
+
// (not prefix-matched) -- a malformed value like "https://[" or a hostless
|
|
78
|
+
// "http://" is rejected, not accepted by a loose regex.
|
|
79
|
+
function _isUrl(v) {
|
|
80
|
+
// Prefilter the exact authority form with no whitespace BEFORE parsing: new URL()
|
|
81
|
+
// silently repairs " https://.." (trim) and "https:host/.." (insert //), and the
|
|
82
|
+
// ORIGINAL string is what gets copied into a protected `url` field, so a repaired
|
|
83
|
+
// value must be rejected here rather than accepted and mismatched at transport time.
|
|
84
|
+
if (!_isString(v) || !/^https?:\/\/[^\s]+$/.test(v)) return false;
|
|
85
|
+
var u;
|
|
86
|
+
// A parse failure IS the "not a URL" verdict for this boolean predicate -- there
|
|
87
|
+
// is no PkiError here to thread a cause into, so the error is intentionally ignored.
|
|
88
|
+
try { u = new URL(v); } catch (_e) { return false; }
|
|
89
|
+
return (u.protocol === "http:" || u.protocol === "https:") && u.hostname.length > 0;
|
|
90
|
+
}
|
|
91
|
+
// A URI string with any RFC 3986 scheme (mailto:, tel:, http(s):, ...). An account
|
|
92
|
+
// `contact` is a URI, most commonly `mailto:` (RFC 8555 sec. 7.1.2 / RFC 6068), so
|
|
93
|
+
// it must NOT be narrowed to http(s); the strict mailto hygiene lives in the builder.
|
|
94
|
+
function _isUriString(v) { return _isString(v) && /^[a-zA-Z][a-zA-Z0-9+.-]*:[^\s]+$/.test(v); }
|
|
95
|
+
|
|
96
|
+
// ---- resource object specs (RFC 8555 sec. 7.1.x) -------------------------
|
|
97
|
+
|
|
98
|
+
// Each field: { name, type, required?, requiredWhen?(obj), enum?, elemType? }.
|
|
99
|
+
// type: "string" | "url" | "rfc3339" | "boolean" | "object" | "array" | "any".
|
|
100
|
+
// The walker validates presence/shape ONLY; unknown fields are ignored (never
|
|
101
|
+
// reflected). requiredWhen is the conditional-required rule (sec. 7.1.3 expires).
|
|
102
|
+
// The assigned RFC 5280 sec. 5.3.1 CRLReason values (0-6, 8-10). Value 7 is
|
|
103
|
+
// unassigned, so a revokeCert reason of 7 is rejected rather than sent.
|
|
104
|
+
var CRL_REASONS = [0, 1, 2, 3, 4, 5, 6, 8, 9, 10];
|
|
105
|
+
|
|
106
|
+
// The registered challenge types (RFC 8555 sec. 8.3/8.4, RFC 8737). For these the
|
|
107
|
+
// `token` is a required, entropy-bearing base64url value; an unknown future type
|
|
108
|
+
// may define its own response fields, so `token` is only required for these.
|
|
109
|
+
var KNOWN_CHALLENGE_TYPES = { "http-01": 1, "dns-01": 1, "tls-alpn-01": 1 };
|
|
110
|
+
|
|
111
|
+
var STATUS = {
|
|
112
|
+
account: ["valid", "deactivated", "revoked"],
|
|
113
|
+
order: ["pending", "ready", "processing", "valid", "invalid"],
|
|
114
|
+
authorization: ["pending", "valid", "invalid", "deactivated", "expired", "revoked"],
|
|
115
|
+
challenge: ["pending", "processing", "valid", "invalid"],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
var SPECS = {
|
|
119
|
+
directory: [
|
|
120
|
+
{ name: "newNonce", type: "url", required: true },
|
|
121
|
+
{ name: "newAccount", type: "url", required: true },
|
|
122
|
+
{ name: "newOrder", type: "url", required: true },
|
|
123
|
+
{ name: "revokeCert", type: "url", required: true },
|
|
124
|
+
{ name: "keyChange", type: "url", required: true },
|
|
125
|
+
{ name: "newAuthz", type: "url" },
|
|
126
|
+
{ name: "renewalInfo", type: "url" },
|
|
127
|
+
{ name: "meta", type: "object" },
|
|
128
|
+
],
|
|
129
|
+
account: [
|
|
130
|
+
{ name: "status", type: "string", required: true, enum: STATUS.account },
|
|
131
|
+
{ name: "contact", type: "array", elemType: "contact" },
|
|
132
|
+
{ name: "termsOfServiceAgreed", type: "boolean" },
|
|
133
|
+
{ name: "externalAccountBinding", type: "object" },
|
|
134
|
+
{ name: "orders", type: "url" }, // required per RFC; lenient by default (OQ2)
|
|
135
|
+
],
|
|
136
|
+
order: [
|
|
137
|
+
{ name: "status", type: "string", required: true, enum: STATUS.order },
|
|
138
|
+
{ name: "expires", type: "rfc3339", requiredWhen: function (o) { return o.status === "pending" || o.status === "valid"; } },
|
|
139
|
+
{ name: "identifiers", type: "array", required: true, minItems: 1, elemType: "orderIdentifier" },
|
|
140
|
+
{ name: "notBefore", type: "rfc3339" },
|
|
141
|
+
{ name: "notAfter", type: "rfc3339" },
|
|
142
|
+
{ name: "error", type: "object" },
|
|
143
|
+
{ name: "authorizations", type: "array", required: true, minItems: 1, elemType: "url" },
|
|
144
|
+
{ name: "finalize", type: "url", required: true },
|
|
145
|
+
{ name: "certificate", type: "url" },
|
|
146
|
+
{ name: "replaces", type: "string" },
|
|
147
|
+
],
|
|
148
|
+
authorization: [
|
|
149
|
+
{ name: "identifier", type: "identifier", required: true },
|
|
150
|
+
{ name: "status", type: "string", required: true, enum: STATUS.authorization },
|
|
151
|
+
{ name: "expires", type: "rfc3339", requiredWhen: function (o) { return o.status === "valid"; } },
|
|
152
|
+
{ name: "challenges", type: "array", required: true, minItems: 1, elemType: "challenge" },
|
|
153
|
+
{ name: "wildcard", type: "boolean" },
|
|
154
|
+
],
|
|
155
|
+
challenge: [
|
|
156
|
+
{ name: "type", type: "string", required: true },
|
|
157
|
+
{ name: "url", type: "url", required: true },
|
|
158
|
+
{ name: "status", type: "string", required: true, enum: STATUS.challenge },
|
|
159
|
+
{ name: "validated", type: "rfc3339", requiredWhen: function (o) { return o.status === "valid"; } },
|
|
160
|
+
{ name: "token", type: "token", requiredWhen: function (o) { return KNOWN_CHALLENGE_TYPES[o.type] === 1; } },
|
|
161
|
+
{ name: "error", type: "object" },
|
|
162
|
+
],
|
|
163
|
+
renewalInfo: [
|
|
164
|
+
{ name: "suggestedWindow", type: "object", required: true },
|
|
165
|
+
{ name: "explanationURL", type: "url" },
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function _checkType(kind, field, value) {
|
|
170
|
+
switch (field.type) {
|
|
171
|
+
case "string": if (!_isString(value)) return "must be a string"; break;
|
|
172
|
+
case "url": if (!_isUrl(value)) return "must be a URL string"; break;
|
|
173
|
+
case "rfc3339": if (!_isRfc3339(value)) return "must be an RFC 3339 date-time"; break;
|
|
174
|
+
case "boolean": if (typeof value !== "boolean") return "must be a boolean"; break;
|
|
175
|
+
case "token": _assertToken(value); break; // >= 22 base64url chars, no padding (throws acme/bad-token)
|
|
176
|
+
case "object": if (!_isObject(value)) return "must be an object"; break;
|
|
177
|
+
case "identifier": _validateIdentifier(value); break;
|
|
178
|
+
case "array":
|
|
179
|
+
if (!Array.isArray(value)) return "must be an array";
|
|
180
|
+
if (field.minItems && value.length < field.minItems) return "must have at least " + field.minItems + " element(s)";
|
|
181
|
+
for (var i = 0; i < value.length; i++) {
|
|
182
|
+
if (field.elemType === "url" && !_isUrl(value[i])) return "element " + i + " must be a URL string";
|
|
183
|
+
if (field.elemType === "contact" && !_isUriString(value[i])) return "element " + i + " must be a URI string";
|
|
184
|
+
if (field.elemType === "identifier") _validateIdentifier(value[i]);
|
|
185
|
+
if (field.elemType === "orderIdentifier") _validateOrderIdentifier(value[i]);
|
|
186
|
+
if (field.elemType === "challenge") _validate("challenge", value[i]);
|
|
187
|
+
if (field.elemType === "object" && !_isObject(value[i])) return "element " + i + " must be an object";
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
default: break; // "any"
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A kind name kebab-cased for use in an error code (the code shape is strict
|
|
196
|
+
// lowercase-kebab): a camelCase kind like "renewalInfo" must become
|
|
197
|
+
// "renewal-info", never leak "acme/bad-renewalInfo" (which the PkiError code
|
|
198
|
+
// validator rejects, turning a fault into a raw TypeError).
|
|
199
|
+
function _codeSlug(kind) { return kind.replace(/([A-Z])/g, "-$1").toLowerCase(); }
|
|
200
|
+
|
|
201
|
+
// Validate an object against a spec table. Returns the object; throws acme/*.
|
|
202
|
+
function _validate(kind, obj) {
|
|
203
|
+
if (!_isObject(obj)) throw E("acme/bad-" + _codeSlug(kind), "an ACME " + kind + " must be a JSON object");
|
|
204
|
+
var spec = SPECS[kind];
|
|
205
|
+
for (var f = 0; f < spec.length; f++) {
|
|
206
|
+
var field = spec[f];
|
|
207
|
+
var present = Object.prototype.hasOwnProperty.call(obj, field.name);
|
|
208
|
+
var required = field.required || (field.requiredWhen && field.requiredWhen(obj));
|
|
209
|
+
if (!present) {
|
|
210
|
+
if (required) throw E("acme/missing-field", "an ACME " + kind + " is missing the required field " + JSON.stringify(field.name));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (field.enum && field.enum.indexOf(obj[field.name]) === -1) {
|
|
214
|
+
throw E("acme/bad-status", "the " + kind + " " + field.name + " " + JSON.stringify(obj[field.name]) + " is not a recognized value");
|
|
215
|
+
}
|
|
216
|
+
var err = _checkType(kind, field, obj[field.name]);
|
|
217
|
+
if (err) throw E("acme/bad-" + _codeSlug(kind), "the " + kind + " field " + JSON.stringify(field.name) + " " + err);
|
|
218
|
+
}
|
|
219
|
+
return obj;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- identifiers (dns / ip; RFC 8555 sec. 7.1.4 / RFC 8738) --------------
|
|
223
|
+
|
|
224
|
+
// A dns identifier value: lowercase LDH ASCII labels (A-labels; a leading `*.`
|
|
225
|
+
// wildcard is validated by the ORDER path, never here). An ip identifier value:
|
|
226
|
+
// the RFC 5952 (IPv6) / RFC 1123 (IPv4) canonical textual form, byte-identical
|
|
227
|
+
// round-trip. `_validateIdentifier` rejects a value beginning `*.` (that is only
|
|
228
|
+
// legal in an order identifier, checked separately).
|
|
229
|
+
function _validateIdentifier(id) {
|
|
230
|
+
if (!_isObject(id) || !_isString(id.type) || !_isString(id.value)) throw E("acme/bad-identifier", "an identifier must be { type, value } strings");
|
|
231
|
+
if (id.type === "dns") {
|
|
232
|
+
if (id.value.indexOf("*.") === 0) throw E("acme/bad-identifier", "a wildcard *. value is not permitted in an authorization identifier (RFC 8555 sec. 7.1.4)");
|
|
233
|
+
_assertDnsName(id.value);
|
|
234
|
+
} else if (id.type === "ip") {
|
|
235
|
+
_assertIpAddress(id.value);
|
|
236
|
+
}
|
|
237
|
+
// An unrecognized identifier type is surfaced raw (a server may add types).
|
|
238
|
+
return id;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// A DNS name (each label lowercase letters/digits/hyphen, not leading/trailing
|
|
242
|
+
// hyphen; an xn-- A-label must be well-formed). Uppercase / non-ASCII rejected
|
|
243
|
+
// (the client sends A-labels only).
|
|
244
|
+
function _assertDnsName(name) {
|
|
245
|
+
if (!_isString(name)) throw E("acme/bad-identifier", "a dns identifier value must be a string");
|
|
246
|
+
if (name.length === 0 || name.length > 253) throw E("acme/bad-identifier", "a dns identifier value must be 1..253 characters");
|
|
247
|
+
var labels = name.split(".");
|
|
248
|
+
for (var i = 0; i < labels.length; i++) {
|
|
249
|
+
var l = labels[i];
|
|
250
|
+
if (l.length > 63) throw E("acme/bad-identifier", "a dns label must be 1..63 characters (RFC 1035 sec. 2.3.4): " + JSON.stringify(l));
|
|
251
|
+
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(l)) throw E("acme/bad-identifier", "a dns label must be lowercase LDH ASCII (A-label): " + JSON.stringify(l));
|
|
252
|
+
if (l.indexOf("xn--") === 0 && !/^xn--[a-z0-9]+(-[a-z0-9]+)*$/.test(l)) throw E("acme/bad-identifier", "a malformed xn-- A-label: " + JSON.stringify(l));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// An IP address in canonical text (RFC 8738 sec. 3): IPv4 dotted-decimal with no
|
|
257
|
+
// leading zeros, or IPv6 in the RFC 5952 sec. 4 compressed lowercase form. The
|
|
258
|
+
// only accepted form is the one that round-trips byte-identically -- an ambiguous
|
|
259
|
+
// value (leading zeros, uppercase hex, an uncompressed run) is rejected, never
|
|
260
|
+
// normalized-and-guessed.
|
|
261
|
+
function _assertIpAddress(value) {
|
|
262
|
+
if (!_isString(value)) throw E("acme/bad-identifier", "an ip identifier value must be a string");
|
|
263
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(value)) {
|
|
264
|
+
var parts = value.split(".");
|
|
265
|
+
for (var i = 0; i < 4; i++) {
|
|
266
|
+
if (!/^(0|[1-9]\d{0,2})$/.test(parts[i]) || parseInt(parts[i], 10) > 255) throw E("acme/bad-identifier", "an ip identifier IPv4 octet is out of range or non-canonical: " + JSON.stringify(value));
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (value.indexOf(":") !== -1) {
|
|
271
|
+
if (value !== value.toLowerCase()) throw E("acme/bad-identifier", "an IPv6 ip identifier must be lowercase (RFC 5952)");
|
|
272
|
+
var canon = _canonicalizeIpv6(value);
|
|
273
|
+
if (canon === null || canon !== value) throw E("acme/bad-identifier", "an ip identifier must be the RFC 5952 canonical IPv6 form: " + JSON.stringify(value));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
throw E("acme/bad-identifier", "an ip identifier value must be an IPv4 or IPv6 textual address (RFC 8738 sec. 3)");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Parse an IPv6 address to its 8 groups then re-emit the RFC 5952 canonical form
|
|
280
|
+
// (lowercase, no leading zeros, the longest zero-run compressed with `::` -- the
|
|
281
|
+
// leftmost when tied, never a single 0 group). Returns null on a malformed input.
|
|
282
|
+
function _canonicalizeIpv6(value) {
|
|
283
|
+
var groups;
|
|
284
|
+
if (value.indexOf("::") !== -1) {
|
|
285
|
+
if (value.indexOf("::") !== value.lastIndexOf("::")) return null; // only one ::
|
|
286
|
+
var halves = value.split("::");
|
|
287
|
+
var left = halves[0] ? halves[0].split(":") : [];
|
|
288
|
+
var right = halves[1] ? halves[1].split(":") : [];
|
|
289
|
+
var fill = 8 - left.length - right.length;
|
|
290
|
+
if (fill < 1) return null;
|
|
291
|
+
groups = left.concat(new Array(fill).fill("0")).concat(right);
|
|
292
|
+
} else {
|
|
293
|
+
groups = value.split(":");
|
|
294
|
+
}
|
|
295
|
+
if (groups.length !== 8) return null;
|
|
296
|
+
var nums = [];
|
|
297
|
+
for (var i = 0; i < 8; i++) {
|
|
298
|
+
if (!/^[0-9a-f]{1,4}$/.test(groups[i])) return null;
|
|
299
|
+
nums.push(parseInt(groups[i], 16));
|
|
300
|
+
}
|
|
301
|
+
var hex = nums.map(function (n) { return n.toString(16); });
|
|
302
|
+
// Longest run of zero groups (>= 2) -> "::"; leftmost on a tie.
|
|
303
|
+
var bestStart = -1, bestLen = 0, curStart = -1, curLen = 0;
|
|
304
|
+
for (var g = 0; g < 8; g++) {
|
|
305
|
+
if (nums[g] === 0) { if (curStart === -1) curStart = g; curLen++; if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; } }
|
|
306
|
+
else { curStart = -1; curLen = 0; }
|
|
307
|
+
}
|
|
308
|
+
if (bestLen < 2) return hex.join(":");
|
|
309
|
+
var head = hex.slice(0, bestStart).join(":");
|
|
310
|
+
var tail = hex.slice(bestStart + bestLen).join(":");
|
|
311
|
+
return head + "::" + tail;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- state machines (RFC 8555 sec. 7.1.6) --------------------------------
|
|
315
|
+
|
|
316
|
+
// Legal transitions as data. An observed transition outside the set is an
|
|
317
|
+
// illegal server transition the client fails closed on (acme/bad-transition).
|
|
318
|
+
var TRANSITIONS = {
|
|
319
|
+
challenge: { pending: ["processing", "valid", "invalid"], processing: ["processing", "valid", "invalid"] },
|
|
320
|
+
authorization: { pending: ["valid", "invalid"], valid: ["expired", "deactivated", "revoked"] },
|
|
321
|
+
order: { pending: ["ready", "invalid"], ready: ["processing", "valid", "invalid"], processing: ["valid", "invalid"] },
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @primitive pki.acme.assertTransition
|
|
326
|
+
* @signature pki.acme.assertTransition(kind, from, to) -> void
|
|
327
|
+
* @since 0.1.25
|
|
328
|
+
* @status experimental
|
|
329
|
+
* @spec RFC 8555
|
|
330
|
+
* @related pki.acme.validate
|
|
331
|
+
*
|
|
332
|
+
* Assert that a status transition of an ACME resource (`kind` =
|
|
333
|
+
* `"challenge"|"authorization"|"order"`) from `from` to `to` is one of the
|
|
334
|
+
* RFC 8555 sec. 7.1.6 legal edges. A same-status observation is allowed (a
|
|
335
|
+
* server may re-report); any other edge throws `acme/bad-transition`.
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* pki.acme.assertTransition("order", "pending", "ready"); // ok
|
|
339
|
+
*/
|
|
340
|
+
function assertTransition(kind, from, to) {
|
|
341
|
+
var table = TRANSITIONS[kind];
|
|
342
|
+
if (!table) throw E("acme/bad-input", "unknown resource kind " + JSON.stringify(kind));
|
|
343
|
+
if (from === to) return;
|
|
344
|
+
// The legal edges -- including every non-terminal state's edge to "invalid" -- are
|
|
345
|
+
// the table. A terminal "valid" order/challenge has NO outgoing edge, so a
|
|
346
|
+
// "valid" -> "invalid" regression is rejected (RFC 8555 sec. 7.1.6 makes valid
|
|
347
|
+
// terminal; a client failing closed while polling must not accept it).
|
|
348
|
+
var allowed = table[from];
|
|
349
|
+
if (!allowed || allowed.indexOf(to) === -1) throw E("acme/bad-transition", "illegal " + kind + " transition " + JSON.stringify(from) + " -> " + JSON.stringify(to) + " (RFC 8555 sec. 7.1.6)");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---- problem documents (RFC 7807 / RFC 8555 sec. 6.7) --------------------
|
|
353
|
+
|
|
354
|
+
var ERROR_NAMESPACE = "urn:ietf:params:acme:error:";
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* @primitive pki.acme.validateProblem
|
|
358
|
+
* @signature pki.acme.validateProblem(obj) -> obj
|
|
359
|
+
* @since 0.1.25
|
|
360
|
+
* @status experimental
|
|
361
|
+
* @spec RFC 8555, RFC 7807, RFC 9773
|
|
362
|
+
* @related pki.acme.validate
|
|
363
|
+
*
|
|
364
|
+
* Validate an ACME problem document (RFC 7807 + RFC 8555 sec. 6.7): a `type` in
|
|
365
|
+
* the `urn:ietf:params:acme:error:` namespace, an optional `detail`, and
|
|
366
|
+
* `subproblems` (each itself a problem document, optionally carrying an
|
|
367
|
+
* `identifier`). A top-level `identifier` is forbidden (sec. 6.7.1) and throws
|
|
368
|
+
* `acme/bad-problem`. Returns the object.
|
|
369
|
+
*
|
|
370
|
+
* @example
|
|
371
|
+
* pki.acme.validateProblem({ type: "urn:ietf:params:acme:error:malformed" });
|
|
372
|
+
*/
|
|
373
|
+
function validateProblem(obj) {
|
|
374
|
+
if (!_isObject(obj)) throw E("acme/bad-problem", "a problem document must be a JSON object");
|
|
375
|
+
if (Object.prototype.hasOwnProperty.call(obj, "identifier")) throw E("acme/bad-problem", "a top-level problem document must not carry an identifier (RFC 8555 sec. 6.7.1)");
|
|
376
|
+
if (!_isString(obj.type) || obj.type.indexOf(ERROR_NAMESPACE) !== 0) throw E("acme/bad-problem", "an ACME problem type must be in the " + ERROR_NAMESPACE + " namespace (RFC 8555 sec. 6.7)");
|
|
377
|
+
if (Object.prototype.hasOwnProperty.call(obj, "subproblems")) {
|
|
378
|
+
if (!Array.isArray(obj.subproblems)) throw E("acme/bad-problem", "subproblems must be an array");
|
|
379
|
+
for (var i = 0; i < obj.subproblems.length; i++) {
|
|
380
|
+
var sub = obj.subproblems[i];
|
|
381
|
+
if (!_isObject(sub) || !_isString(sub.type) || sub.type.indexOf(ERROR_NAMESPACE) !== 0) throw E("acme/bad-problem", "each subproblem must be an ACME problem document in the error namespace");
|
|
382
|
+
// A subproblem identifier reflects a submitted order identifier, which MAY be a
|
|
383
|
+
// wildcard (a rejectedIdentifier for a *.example.org order), so it is validated
|
|
384
|
+
// with the order-identifier rule, not the stricter authorization one.
|
|
385
|
+
if (Object.prototype.hasOwnProperty.call(sub, "identifier")) _validateOrderIdentifier(sub.identifier);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return obj;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---- object validators + identify ----------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @primitive pki.acme.validate
|
|
395
|
+
* @signature pki.acme.validate(kind, obj) -> obj
|
|
396
|
+
* @since 0.1.25
|
|
397
|
+
* @status experimental
|
|
398
|
+
* @spec RFC 8555, RFC 9773
|
|
399
|
+
* @related pki.acme.identify, pki.acme.validateProblem
|
|
400
|
+
*
|
|
401
|
+
* Validate an ACME resource object of a known `kind` (`"directory"` |
|
|
402
|
+
* `"account"` | `"order"` | `"authorization"` | `"challenge"` | `"renewalInfo"`)
|
|
403
|
+
* against its RFC 8555 / RFC 9773 spec: required and conditionally-required
|
|
404
|
+
* fields, closed status enums, URL / RFC 3339 / identifier shapes, and array
|
|
405
|
+
* minimums. Unknown fields are ignored (never reflected). Throws a typed
|
|
406
|
+
* `acme/*` fault; returns the object.
|
|
407
|
+
*
|
|
408
|
+
* @example
|
|
409
|
+
* pki.acme.validate("order", orderObj).status; // -> "pending"
|
|
410
|
+
*/
|
|
411
|
+
function validate(kind, obj) {
|
|
412
|
+
if (kind === "problem") return validateProblem(obj);
|
|
413
|
+
// renewalInfo carries an RFC 9773 window sanity check beyond the spec shape, so a
|
|
414
|
+
// generic dispatch (identify -> validate) gets the SAME strictness as a direct
|
|
415
|
+
// validateRenewalInfo call -- an inverted / malformed suggestedWindow is rejected.
|
|
416
|
+
if (kind === "renewalInfo") return validateRenewalInfo(obj);
|
|
417
|
+
if (!SPECS[kind]) throw E("acme/bad-input", "unknown ACME object kind " + JSON.stringify(kind));
|
|
418
|
+
return _validate(kind, obj);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @primitive pki.acme.identify
|
|
423
|
+
* @signature pki.acme.identify(obj) -> string
|
|
424
|
+
* @since 0.1.25
|
|
425
|
+
* @status experimental
|
|
426
|
+
* @spec RFC 8555, RFC 9773
|
|
427
|
+
* @related pki.acme.validate
|
|
428
|
+
*
|
|
429
|
+
* Classify an ACME JSON object into exactly one kind by its discriminating
|
|
430
|
+
* member set -- `"jws"`, `"problem"`, `"directory"`, `"order"`, `"authorization"`,
|
|
431
|
+
* `"challenge"`, `"account"`, `"renewalInfo"`, or `"unknown"`. The discriminators
|
|
432
|
+
* are proven mutually exclusive; a DER structure identifies as `"unknown"`.
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* pki.acme.identify(orderObj); // -> "order"
|
|
436
|
+
*/
|
|
437
|
+
function identify(obj) {
|
|
438
|
+
if (!_isObject(obj)) return "unknown";
|
|
439
|
+
var has = function (k) { return Object.prototype.hasOwnProperty.call(obj, k); };
|
|
440
|
+
if (has("protected") && has("signature") && _isString(obj.protected) && _isString(obj.signature)) return "jws";
|
|
441
|
+
if (_isString(obj.type) && obj.type.indexOf(ERROR_NAMESPACE) === 0) return "problem";
|
|
442
|
+
if (has("newNonce") && has("newAccount")) return "directory";
|
|
443
|
+
if (has("suggestedWindow")) return "renewalInfo";
|
|
444
|
+
if (has("finalize") && has("authorizations")) return "order";
|
|
445
|
+
if (has("identifier") && has("challenges")) return "authorization";
|
|
446
|
+
if (has("type") && has("url") && has("token") && !has("identifier")) return "challenge";
|
|
447
|
+
if (has("status") && (has("orders") || has("contact") || has("termsOfServiceAgreed"))) return "account";
|
|
448
|
+
return "unknown";
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ---- challenge computations (RFC 8555 sec. 8 / 8737 / 8738) --------------
|
|
452
|
+
|
|
453
|
+
// A challenge token: >= 128 bits of entropy => >= 22 base64url chars, alphabet
|
|
454
|
+
// only, no `=` (RFC 8555 sec. 8, errata 6950). Validated BEFORE any use (also the
|
|
455
|
+
// http-01 reflection-XSS guard).
|
|
456
|
+
var TOKEN_RE = new RegExp("^[A-Za-z0-9_-]{" + constants.LIMITS.ACME_TOKEN_MIN_CHARS + ",}$");
|
|
457
|
+
function _assertToken(token) {
|
|
458
|
+
if (!_isString(token) || !TOKEN_RE.test(token)) throw E("acme/bad-token", "a challenge token must be >= " + constants.LIMITS.ACME_TOKEN_MIN_CHARS + " base64url characters with no padding (RFC 8555 sec. 8)");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function _sha256(bytes) { return Buffer.from(await subtle.digest("SHA-256", bytes)); }
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @primitive pki.acme.keyAuthorization
|
|
465
|
+
* @signature pki.acme.keyAuthorization(token, accountJwk) -> Promise<string>
|
|
466
|
+
* @since 0.1.25
|
|
467
|
+
* @status experimental
|
|
468
|
+
* @spec RFC 8555, RFC 7638
|
|
469
|
+
* @related pki.acme.http01, pki.acme.dns01, pki.acme.tlsAlpn01Extension
|
|
470
|
+
*
|
|
471
|
+
* The RFC 8555 sec. 8.1 key authorization: `token || '.' ||
|
|
472
|
+
* base64url(SHA-256 JWK thumbprint of the account key)`. The token is validated
|
|
473
|
+
* (entropy floor + alphabet) first; the thumbprint is the RFC 7638 canonical
|
|
474
|
+
* digest, so changing the account key changes the key authorization.
|
|
475
|
+
*
|
|
476
|
+
* @example
|
|
477
|
+
* await pki.acme.keyAuthorization(token, accountJwk); // -> "<token>.<thumbprint>"
|
|
478
|
+
*/
|
|
479
|
+
async function keyAuthorization(token, accountJwk) {
|
|
480
|
+
_assertToken(token);
|
|
481
|
+
var tp = await jose.thumbprint(accountJwk);
|
|
482
|
+
return token + "." + tp;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* @primitive pki.acme.http01
|
|
487
|
+
* @signature pki.acme.http01(token, accountJwk) -> Promise<{ path, body }>
|
|
488
|
+
* @since 0.1.25
|
|
489
|
+
* @status experimental
|
|
490
|
+
* @spec RFC 8555
|
|
491
|
+
* @related pki.acme.keyAuthorization
|
|
492
|
+
*
|
|
493
|
+
* The http-01 challenge computation (RFC 8555 sec. 8.3): the resource `path`
|
|
494
|
+
* `/.well-known/acme-challenge/<token>` and the `body` (the ASCII key
|
|
495
|
+
* authorization, no trailing newline). Validation reaches TCP port 80 over HTTP.
|
|
496
|
+
*
|
|
497
|
+
* @example
|
|
498
|
+
* var c = await pki.acme.http01(token, accountJwk);
|
|
499
|
+
* c.path; // -> "/.well-known/acme-challenge/<token>"
|
|
500
|
+
*/
|
|
501
|
+
async function http01(token, accountJwk) {
|
|
502
|
+
var ka = await keyAuthorization(token, accountJwk);
|
|
503
|
+
return { path: "/.well-known/acme-challenge/" + token, body: ka };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* @primitive pki.acme.dns01
|
|
508
|
+
* @signature pki.acme.dns01(token, accountJwk, domain) -> Promise<{ name, value }>
|
|
509
|
+
* @since 0.1.25
|
|
510
|
+
* @status experimental
|
|
511
|
+
* @spec RFC 8555
|
|
512
|
+
* @related pki.acme.keyAuthorization
|
|
513
|
+
*
|
|
514
|
+
* The dns-01 challenge computation (RFC 8555 sec. 8.4): the TXT record `name`
|
|
515
|
+
* `_acme-challenge.<domain>` (exactly one leading `*.` is stripped for a wildcard
|
|
516
|
+
* order) and the `value` `base64url(SHA-256(keyAuthorization))`.
|
|
517
|
+
*
|
|
518
|
+
* @example
|
|
519
|
+
* var r = await pki.acme.dns01(token, accountJwk, "example.org");
|
|
520
|
+
* r.name; // -> "_acme-challenge.example.org"
|
|
521
|
+
*/
|
|
522
|
+
async function dns01(token, accountJwk, domain) {
|
|
523
|
+
if (!_isString(domain)) throw E("acme/bad-identifier", "dns01 requires a domain string");
|
|
524
|
+
var base = domain.indexOf("*.") === 0 ? domain.slice(2) : domain;
|
|
525
|
+
_assertDnsName(base);
|
|
526
|
+
var ka = await keyAuthorization(token, accountJwk);
|
|
527
|
+
return { name: "_acme-challenge." + base, value: jose.base64url.encode(await _sha256(Buffer.from(ka, "ascii"))) };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
var OID_ACME_IDENTIFIER = oid.byName("acmeIdentifier");
|
|
531
|
+
var OID_SAN = oid.byName("subjectAltName");
|
|
532
|
+
var OID_AKI = oid.byName("authorityKeyIdentifier");
|
|
533
|
+
var OID_CN = oid.byName("commonName");
|
|
534
|
+
var _extNs = pkix.makeNS("acme", AcmeError, oid);
|
|
535
|
+
var _extDecoders = pkix.certExtensionDecoders(_extNs);
|
|
536
|
+
var _extCtx = { E: function (c, m, cause) { return new AcmeError(c, m, cause); }, oid: oid };
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* @primitive pki.acme.tlsAlpn01Extension
|
|
540
|
+
* @signature pki.acme.tlsAlpn01Extension(token, accountJwk) -> Promise<Buffer>
|
|
541
|
+
* @since 0.1.25
|
|
542
|
+
* @status experimental
|
|
543
|
+
* @spec RFC 8737
|
|
544
|
+
* @related pki.acme.verifyTlsAlpn01
|
|
545
|
+
*
|
|
546
|
+
* Build the DER of the critical `id-pe-acmeIdentifier` extension (RFC 8737
|
|
547
|
+
* sec. 3): `SEQUENCE { extnID 1.3.6.1.5.5.7.1.31, critical TRUE, extnValue OCTET
|
|
548
|
+
* STRING wrapping Authorization ::= OCTET STRING (SIZE 32) of the
|
|
549
|
+
* SHA-256(keyAuthorization) }`. Placed in the validation certificate.
|
|
550
|
+
*
|
|
551
|
+
* @example
|
|
552
|
+
* var extDer = await pki.acme.tlsAlpn01Extension(token, accountJwk);
|
|
553
|
+
*/
|
|
554
|
+
async function tlsAlpn01Extension(token, accountJwk) {
|
|
555
|
+
var ka = await keyAuthorization(token, accountJwk);
|
|
556
|
+
var digest = await _sha256(Buffer.from(ka, "ascii")); // 32 bytes
|
|
557
|
+
var authorization = asn1.build.octetString(digest); // Authorization ::= OCTET STRING (SIZE 32)
|
|
558
|
+
var extnValue = asn1.build.octetString(authorization);
|
|
559
|
+
return asn1.build.sequence([asn1.build.oid(OID_ACME_IDENTIFIER), asn1.build.boolean(true), extnValue]);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// The 32-byte Authorization digest inside an acmeIdentifier extnValue, or throw.
|
|
563
|
+
function _readAcmeIdentifier(extnValue) {
|
|
564
|
+
var auth;
|
|
565
|
+
try { auth = asn1.read.octetString(asn1.decode(extnValue)); }
|
|
566
|
+
catch (e) { throw E("acme/bad-tlsalpn", "the acmeIdentifier extnValue is not a well-formed Authorization OCTET STRING", e); }
|
|
567
|
+
if (auth.length !== 32) throw E("acme/bad-tlsalpn", "the acmeIdentifier Authorization must be exactly 32 octets (RFC 8737 sec. 3)");
|
|
568
|
+
return auth;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* @primitive pki.acme.verifyTlsAlpn01
|
|
573
|
+
* @signature pki.acme.verifyTlsAlpn01(certDer, token, accountJwk, identifier) -> Promise<void>
|
|
574
|
+
* @since 0.1.25
|
|
575
|
+
* @status experimental
|
|
576
|
+
* @spec RFC 8737, RFC 8738
|
|
577
|
+
* @related pki.acme.tlsAlpn01Extension
|
|
578
|
+
*
|
|
579
|
+
* Verify a tls-alpn-01 validation certificate (RFC 8737 sec. 3): a CRITICAL
|
|
580
|
+
* `id-pe-acmeIdentifier` extension whose 32-octet Authorization equals
|
|
581
|
+
* SHA-256(keyAuthorization), AND a SubjectAltName with EXACTLY ONE entry -- a
|
|
582
|
+
* dNSName equal to the `dns` identifier (case-insensitive) or a single iPAddress
|
|
583
|
+
* for an `ip` identifier (RFC 8738 sec. 6). Any deviation throws `acme/bad-tlsalpn`.
|
|
584
|
+
*
|
|
585
|
+
* @example
|
|
586
|
+
* await pki.acme.verifyTlsAlpn01(certDer, token, accountJwk, { type: "dns", value: "example.org" });
|
|
587
|
+
*/
|
|
588
|
+
async function verifyTlsAlpn01(certDer, token, accountJwk, identifier) {
|
|
589
|
+
var cert = x509.parse(certDer);
|
|
590
|
+
var exts = cert.extensions || [];
|
|
591
|
+
var acmeExt = exts.filter(function (e) { return e.oid === OID_ACME_IDENTIFIER; })[0];
|
|
592
|
+
if (!acmeExt) throw E("acme/bad-tlsalpn", "the validation certificate is missing the acmeIdentifier extension (RFC 8737 sec. 3)");
|
|
593
|
+
if (!acmeExt.critical) throw E("acme/bad-tlsalpn", "the acmeIdentifier extension must be critical (RFC 8737 sec. 3)");
|
|
594
|
+
var auth = _readAcmeIdentifier(acmeExt.value);
|
|
595
|
+
var ka = await keyAuthorization(token, accountJwk);
|
|
596
|
+
var expected = await _sha256(Buffer.from(ka, "ascii"));
|
|
597
|
+
if (!auth.equals(expected)) throw E("acme/bad-tlsalpn", "the acmeIdentifier digest does not match the key authorization");
|
|
598
|
+
// SAN: exactly one entry, of the identifier's type, equal to its value.
|
|
599
|
+
var sanExt = exts.filter(function (e) { return e.oid === OID_SAN; })[0];
|
|
600
|
+
if (!sanExt) throw E("acme/bad-tlsalpn", "the validation certificate is missing the SubjectAltName");
|
|
601
|
+
var san = _extDecoders.byOid[OID_SAN](sanExt.value, _extCtx);
|
|
602
|
+
if (!san.names || san.names.length !== 1) throw E("acme/bad-tlsalpn", "the SubjectAltName must carry EXACTLY ONE entry (RFC 8737 sec. 3)");
|
|
603
|
+
var entry = san.names[0];
|
|
604
|
+
if (!_isObject(identifier) || !_isString(identifier.type)) throw E("acme/bad-input", "an identifier { type, value } is required");
|
|
605
|
+
if (identifier.type === "dns") {
|
|
606
|
+
if (entry.tagNumber !== 2) throw E("acme/bad-tlsalpn", "a dns identifier requires a dNSName SAN");
|
|
607
|
+
// Validate the identifier as a base dns name first (rejecting a wildcard *.
|
|
608
|
+
// label or a malformed value), matching the ip branch and the rest of the
|
|
609
|
+
// ACME path -- a tls-alpn-01 identifier must be a concrete, non-wildcard name.
|
|
610
|
+
_assertDnsName(identifier.value);
|
|
611
|
+
if (String(entry.value).toLowerCase() !== identifier.value) throw E("acme/bad-tlsalpn", "the SAN dNSName does not match the identifier");
|
|
612
|
+
} else if (identifier.type === "ip") {
|
|
613
|
+
if (entry.tagNumber !== 7) throw E("acme/bad-tlsalpn", "an ip identifier requires a single iPAddress SAN (RFC 8738 sec. 6)");
|
|
614
|
+
// Reject a NON-canonical identifier (leading-zero octets, uppercase IPv6, an
|
|
615
|
+
// uncompressed run) rather than normalizing-and-guessing it -- the same
|
|
616
|
+
// fail-closed rule the rest of the ACME identifier path applies. After this the
|
|
617
|
+
// identifier value is its own canonical form, so the SAN's canonical text must equal it.
|
|
618
|
+
_assertIpAddress(identifier.value);
|
|
619
|
+
var sanIp = _ipBytesToText(entry.value);
|
|
620
|
+
if (sanIp === null || sanIp !== identifier.value) throw E("acme/bad-tlsalpn", "the iPAddress SAN does not match the ip identifier (RFC 8738 sec. 6)");
|
|
621
|
+
} else {
|
|
622
|
+
throw E("acme/bad-tlsalpn", "unsupported tls-alpn-01 identifier type " + JSON.stringify(identifier.type));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---- request builders (RFC 8555 sec. 7.x) --------------------------------
|
|
627
|
+
|
|
628
|
+
// The signed outer request is a Flattened JWS under the acme-outer profile: alg
|
|
629
|
+
// + nonce + url + EXACTLY ONE of kid/jwk (jose enforces one-of, nonce, url). The
|
|
630
|
+
// payload is a raw Buffer -- POST-as-GET is an empty Buffer (encodes to ""), a
|
|
631
|
+
// resource POST is the UTF-8 JSON of the payload object. `o` carries the signing
|
|
632
|
+
// key (`key`, a private CryptoKey), its `alg`, the fresh `nonce`, the target
|
|
633
|
+
// `url`, and either `kid` (an account URL) or `jwk` (an embedded public JWK).
|
|
634
|
+
function _outerHeader(o) {
|
|
635
|
+
var h = { alg: o.alg, nonce: o.nonce, url: o.url };
|
|
636
|
+
if (Object.prototype.hasOwnProperty.call(o, "kid")) h.kid = o.kid;
|
|
637
|
+
if (Object.prototype.hasOwnProperty.call(o, "jwk")) h.jwk = o.jwk;
|
|
638
|
+
return h;
|
|
639
|
+
}
|
|
640
|
+
function _payloadBuf(obj) {
|
|
641
|
+
if (obj === undefined) return Buffer.alloc(0); // POST-as-GET
|
|
642
|
+
return Buffer.from(JSON.stringify(obj), "utf8");
|
|
643
|
+
}
|
|
644
|
+
function _signOuter(o, payloadObj) {
|
|
645
|
+
if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
|
|
646
|
+
if (!o.key) throw E("acme/bad-input", "a signing key (opts.key) is required");
|
|
647
|
+
return jose.sign({ protected: _outerHeader(o), payload: _payloadBuf(payloadObj), key: o.key, jwk: o.jwk, profile: "acme-outer" });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* @primitive pki.acme.postAsGet
|
|
652
|
+
* @signature pki.acme.postAsGet(opts) -> Promise<object>
|
|
653
|
+
* @since 0.1.25
|
|
654
|
+
* @status experimental
|
|
655
|
+
* @spec RFC 8555
|
|
656
|
+
* @related pki.acme.newOrder
|
|
657
|
+
*
|
|
658
|
+
* Build a POST-as-GET request (RFC 8555 sec. 6.3): a JWS whose payload is the
|
|
659
|
+
* EMPTY octet string (`payload: ""`), distinct from a POST of an empty object
|
|
660
|
+
* (`{}`). `opts` carries `{ key, alg, nonce, url, kid }` (an authenticated read
|
|
661
|
+
* is always kid-signed). Returns the flattened JWS.
|
|
662
|
+
*
|
|
663
|
+
* @example
|
|
664
|
+
* await pki.acme.postAsGet({ key, alg: "ES256", nonce, url: orderUrl, kid });
|
|
665
|
+
*/
|
|
666
|
+
function postAsGet(o) {
|
|
667
|
+
if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
|
|
668
|
+
// An authenticated read is ALWAYS kid-signed; copy only the kid-mode fields so a
|
|
669
|
+
// leftover jwk (e.g. reused from a newAccount options object) cannot embed a key.
|
|
670
|
+
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, undefined);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// A contact URL (RFC 8555 sec. 7.3): a `mailto:` addr-spec carries no header
|
|
674
|
+
// fields (`?` hfields) and exactly one address (no comma-list) -- fail closed on
|
|
675
|
+
// ambiguity (never send to a guessed recipient). Other URL schemes pass through
|
|
676
|
+
// as opaque strings (the server decides support via unsupportedContact).
|
|
677
|
+
function _assertContacts(contacts) {
|
|
678
|
+
if (!Array.isArray(contacts)) throw E("acme/bad-contact", "contact must be an array of URL strings");
|
|
679
|
+
contacts.forEach(function (c) {
|
|
680
|
+
if (!_isUriString(c)) throw E("acme/bad-contact", "each contact must be a URI string (RFC 8555 sec. 7.1.2)");
|
|
681
|
+
// URI schemes are case-insensitive (RFC 3986), so detect mailto regardless of
|
|
682
|
+
// case -- "MAILTO:a@b?..." must still hit the RFC 6068 header-field guards.
|
|
683
|
+
if (c.slice(0, "mailto:".length).toLowerCase() === "mailto:") {
|
|
684
|
+
var addr = c.slice("mailto:".length);
|
|
685
|
+
if (addr.indexOf("?") !== -1) throw E("acme/bad-contact", "a mailto contact must not carry header fields (RFC 8555 sec. 7.3 / RFC 6068)");
|
|
686
|
+
if (addr.indexOf(",") !== -1) throw E("acme/bad-contact", "a mailto contact must be a single addr-spec, not a comma list (RFC 8555 sec. 7.3)");
|
|
687
|
+
if ((addr.match(/@/g) || []).length !== 1) throw E("acme/bad-contact", "a mailto contact must be exactly one addr-spec");
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* @primitive pki.acme.newAccount
|
|
694
|
+
* @signature pki.acme.newAccount(opts) -> Promise<object>
|
|
695
|
+
* @since 0.1.25
|
|
696
|
+
* @status experimental
|
|
697
|
+
* @spec RFC 8555
|
|
698
|
+
* @related pki.acme.externalAccountBinding
|
|
699
|
+
*
|
|
700
|
+
* Build a newAccount request (RFC 8555 sec. 7.3): a jwk-signed JWS (a new account
|
|
701
|
+
* has no kid yet) whose payload MAY carry `contact` (mailto validated fail-closed),
|
|
702
|
+
* `termsOfServiceAgreed`, `onlyReturnExisting`, and an `externalAccountBinding`
|
|
703
|
+
* (an EAB inner JWS from `externalAccountBinding`). `opts` = `{ key, alg, nonce,
|
|
704
|
+
* url, jwk, contact?, termsOfServiceAgreed?, onlyReturnExisting?, externalAccountBinding? }`.
|
|
705
|
+
*
|
|
706
|
+
* @example
|
|
707
|
+
* await pki.acme.newAccount({ key, alg: "ES256", nonce, url, jwk, termsOfServiceAgreed: true });
|
|
708
|
+
*/
|
|
709
|
+
function newAccount(o) {
|
|
710
|
+
if (!_isObject(o) || !_isObject(o.jwk)) throw E("acme/bad-input", "newAccount must embed the account public jwk (RFC 8555 sec. 7.3)");
|
|
711
|
+
var payload = {};
|
|
712
|
+
if (o.contact !== undefined) { _assertContacts(o.contact); payload.contact = o.contact; }
|
|
713
|
+
// Require actual booleans -- never coerce a string like "false"/"0", which `!!`
|
|
714
|
+
// would serialize as `true`, silently agreeing to Terms of Service or forcing
|
|
715
|
+
// onlyReturnExisting.
|
|
716
|
+
if (o.termsOfServiceAgreed !== undefined) {
|
|
717
|
+
if (typeof o.termsOfServiceAgreed !== "boolean") throw E("acme/bad-input", "termsOfServiceAgreed must be a boolean");
|
|
718
|
+
payload.termsOfServiceAgreed = o.termsOfServiceAgreed;
|
|
719
|
+
}
|
|
720
|
+
if (o.onlyReturnExisting !== undefined) {
|
|
721
|
+
if (typeof o.onlyReturnExisting !== "boolean") throw E("acme/bad-input", "onlyReturnExisting must be a boolean");
|
|
722
|
+
payload.onlyReturnExisting = o.onlyReturnExisting;
|
|
723
|
+
}
|
|
724
|
+
if (o.externalAccountBinding !== undefined) {
|
|
725
|
+
if (!_isObject(o.externalAccountBinding)) throw E("acme/bad-input", "externalAccountBinding must be an EAB inner JWS object");
|
|
726
|
+
payload.externalAccountBinding = o.externalAccountBinding;
|
|
727
|
+
}
|
|
728
|
+
return jose.sign({ protected: { alg: o.alg, nonce: o.nonce, url: o.url, jwk: o.jwk }, payload: _payloadBuf(payload), key: o.key, jwk: o.jwk, profile: "acme-outer" });
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
var _HMAC_HASH = { HS256: "SHA-256", HS384: "SHA-384", HS512: "SHA-512" };
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* @primitive pki.acme.externalAccountBinding
|
|
735
|
+
* @signature pki.acme.externalAccountBinding(opts) -> Promise<object>
|
|
736
|
+
* @since 0.1.25
|
|
737
|
+
* @status experimental
|
|
738
|
+
* @spec RFC 8555
|
|
739
|
+
* @related pki.acme.newAccount
|
|
740
|
+
*
|
|
741
|
+
* Build the External Account Binding inner JWS (RFC 8555 sec. 7.3.4): a MAC-only
|
|
742
|
+
* (`HS256`/`HS384`/`HS512`) JWS over the account public JWK, keyed by the CA-issued
|
|
743
|
+
* `kid` + symmetric `macKey` (a raw `Buffer` or an HMAC `CryptoKey`), `url` equal to
|
|
744
|
+
* the newAccount URL, NO nonce. `opts` = `{ macKey, kid, url, accountJwk, alg? }`
|
|
745
|
+
* (alg default `HS256`). The result is embedded as newAccount's `externalAccountBinding`.
|
|
746
|
+
*
|
|
747
|
+
* @example
|
|
748
|
+
* var eab = await pki.acme.externalAccountBinding({ macKey, kid: "abc123", url, accountJwk });
|
|
749
|
+
*/
|
|
750
|
+
async function externalAccountBinding(o) {
|
|
751
|
+
if (!_isObject(o) || !_isObject(o.accountJwk)) throw E("acme/bad-input", "externalAccountBinding requires the account public jwk (sec. 7.3.4)");
|
|
752
|
+
jose.assertPublicJwk(o.accountJwk); // the account JWK is published in the EAB payload -> public-only
|
|
753
|
+
if (!_isString(o.kid)) throw E("acme/bad-input", "externalAccountBinding requires the CA-issued kid");
|
|
754
|
+
var alg = o.alg || "HS256";
|
|
755
|
+
if (!_HMAC_HASH[alg]) throw E("acme/bad-input", "an EAB inner JWS must use an HS* MAC algorithm (sec. 7.3.4), not " + JSON.stringify(alg));
|
|
756
|
+
var key = o.macKey;
|
|
757
|
+
if (Buffer.isBuffer(key)) {
|
|
758
|
+
try { key = await subtle.importKey("raw", key, { name: "HMAC", hash: _HMAC_HASH[alg] }, false, ["sign"]); }
|
|
759
|
+
catch (e) { throw E("acme/bad-input", "the EAB macKey could not be imported as an HMAC key", e); }
|
|
760
|
+
} else if (!key || typeof key !== "object" || key.type !== "secret") {
|
|
761
|
+
// Anything that is neither a raw Buffer nor a secret-key CryptoKey would reach
|
|
762
|
+
// subtle.sign and throw a bare TypeError; fail closed with a typed fault instead.
|
|
763
|
+
throw E("acme/bad-input", "the EAB macKey must be a raw Buffer or an HMAC (secret) CryptoKey");
|
|
764
|
+
}
|
|
765
|
+
return jose.sign({ protected: { alg: alg, kid: o.kid, url: o.url }, payload: _payloadBuf(o.accountJwk), key: key, profile: "eab-inner" });
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// An order identifier MAY carry a wildcard: EXACTLY ONE leading `*.` label, `dns`
|
|
769
|
+
// only (sec. 7.1.3). An `ip` identifier has no wildcard form. Everything else is
|
|
770
|
+
// the shared _validateIdentifier syntax on the base name.
|
|
771
|
+
function _validateOrderIdentifier(id) {
|
|
772
|
+
if (!_isObject(id) || !_isString(id.type) || !_isString(id.value)) throw E("acme/bad-identifier", "an order identifier must be { type, value } strings");
|
|
773
|
+
if (id.type === "dns") {
|
|
774
|
+
var v = id.value;
|
|
775
|
+
if (v.indexOf("*.") === 0) {
|
|
776
|
+
v = v.slice(2);
|
|
777
|
+
if (v.indexOf("*") !== -1) throw E("acme/bad-identifier", "a wildcard order identifier permits exactly one leading *. label (RFC 8555 sec. 7.1.3)");
|
|
778
|
+
} else if (v.indexOf("*") !== -1) {
|
|
779
|
+
throw E("acme/bad-identifier", "a wildcard must be a single leading *. label (RFC 8555 sec. 7.1.3)");
|
|
780
|
+
}
|
|
781
|
+
_assertDnsName(v);
|
|
782
|
+
} else if (id.type === "ip") {
|
|
783
|
+
if (id.value.indexOf("*") !== -1) throw E("acme/bad-identifier", "an ip identifier has no wildcard form (RFC 8738)");
|
|
784
|
+
_assertIpAddress(id.value);
|
|
785
|
+
}
|
|
786
|
+
return id;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* @primitive pki.acme.newOrder
|
|
791
|
+
* @signature pki.acme.newOrder(opts) -> Promise<object>
|
|
792
|
+
* @since 0.1.25
|
|
793
|
+
* @status experimental
|
|
794
|
+
* @spec RFC 8555, RFC 9773
|
|
795
|
+
* @related pki.acme.finalize, pki.acme.ariCertId
|
|
796
|
+
*
|
|
797
|
+
* Build a newOrder request (RFC 8555 sec. 7.4): a kid-signed JWS whose payload
|
|
798
|
+
* carries a non-empty validated `identifiers` array (each `dns`/`ip`, one leading
|
|
799
|
+
* `*.` wildcard permitted for `dns`), optional `notBefore`/`notAfter`, and an
|
|
800
|
+
* optional RFC 9773 `replaces` (the ARI certID of the certificate being renewed).
|
|
801
|
+
* `opts` = `{ key, alg, nonce, url, kid, identifiers, notBefore?, notAfter?, replaces? }`.
|
|
802
|
+
*
|
|
803
|
+
* @example
|
|
804
|
+
* await pki.acme.newOrder({ key, alg: "ES256", nonce, url, kid, identifiers: [{ type: "dns", value: "example.org" }] });
|
|
805
|
+
*/
|
|
806
|
+
function newOrder(o) {
|
|
807
|
+
if (!_isObject(o) || !Array.isArray(o.identifiers) || o.identifiers.length === 0) throw E("acme/bad-order", "newOrder requires a non-empty identifiers array (RFC 8555 sec. 7.4)");
|
|
808
|
+
o.identifiers.forEach(_validateOrderIdentifier);
|
|
809
|
+
var payload = { identifiers: o.identifiers };
|
|
810
|
+
if (o.notBefore !== undefined) { if (!_isRfc3339(o.notBefore)) throw E("acme/bad-order", "notBefore must be an RFC 3339 date-time"); payload.notBefore = o.notBefore; }
|
|
811
|
+
if (o.notAfter !== undefined) { if (!_isRfc3339(o.notAfter)) throw E("acme/bad-order", "notAfter must be an RFC 3339 date-time"); payload.notAfter = o.notAfter; }
|
|
812
|
+
if (o.replaces !== undefined) { if (!_isString(o.replaces)) throw E("acme/bad-order", "replaces must be an ARI certID string (RFC 9773 sec. 5)"); payload.replaces = o.replaces; }
|
|
813
|
+
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// The account key's SubjectPublicKeyInfo DER, imported from its public JWK, so a
|
|
817
|
+
// finalize CSR carrying that same key is caught (sec. 11.1). The DER is canonical
|
|
818
|
+
// (node emits canonical SPKI), matched byte-for-byte against the CSR's strict-DER SPKI.
|
|
819
|
+
async function _jwkToSpki(jwk) {
|
|
820
|
+
var importAlg;
|
|
821
|
+
if (jwk.kty === "EC") importAlg = { name: "ECDSA", namedCurve: jwk.crv };
|
|
822
|
+
else if (jwk.kty === "RSA") importAlg = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
|
|
823
|
+
else if (jwk.kty === "OKP") importAlg = { name: jwk.crv };
|
|
824
|
+
else if (jwk.kty === "AKP") importAlg = { name: jwk.alg };
|
|
825
|
+
else throw E("acme/bad-key", "unsupported account key type " + JSON.stringify(jwk && jwk.kty));
|
|
826
|
+
var key;
|
|
827
|
+
try { key = await subtle.importKey("jwk", jwk, importAlg, true, []); }
|
|
828
|
+
catch (e) { throw E("acme/bad-key", "the account key JWK could not be imported to derive its SubjectPublicKeyInfo", e); }
|
|
829
|
+
return Buffer.from(await subtle.exportKey("spki", key));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// EVERY common name in the subject (a DN may carry more than one CN RDN) -- taking
|
|
833
|
+
// only the first would let a second CN smuggle an identifier past the order-set match.
|
|
834
|
+
function _subjectCommonNames(subject) {
|
|
835
|
+
var names = [];
|
|
836
|
+
if (!subject || !Array.isArray(subject.rdns)) return names;
|
|
837
|
+
subject.rdns.forEach(function (atvs) {
|
|
838
|
+
atvs.forEach(function (atv) {
|
|
839
|
+
if ((atv.name === "commonName" || atv.type === OID_CN) && _isString(atv.value)) names.push(atv.value);
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
return names;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// An iPAddress SAN octet string (4 = IPv4, 16 = IPv6) to its RFC 8738 canonical
|
|
846
|
+
// text -- the same form an order ip identifier carries -- or null on a bad length.
|
|
847
|
+
function _ipBytesToText(buf) {
|
|
848
|
+
if (!Buffer.isBuffer(buf)) return null;
|
|
849
|
+
if (buf.length === 4) return buf[0] + "." + buf[1] + "." + buf[2] + "." + buf[3];
|
|
850
|
+
if (buf.length === 16) {
|
|
851
|
+
var groups = [];
|
|
852
|
+
for (var i = 0; i < 16; i += 2) groups.push(((buf[i] << 8) | buf[i + 1]).toString(16));
|
|
853
|
+
return _canonicalizeIpv6(groups.join(":"));
|
|
854
|
+
}
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// The set of identifiers a CSR requests: the subject CN (counted as dns) plus the
|
|
859
|
+
// SAN dNSName / iPAddress entries in the extensionRequest attribute, keyed
|
|
860
|
+
// "dns:<lower>" / "ip:<canonical>" for an order-insensitive compare.
|
|
861
|
+
function _csrIdentifierSet(parsedCsr) {
|
|
862
|
+
var set = {};
|
|
863
|
+
_subjectCommonNames(parsedCsr.subject).forEach(function (cn) { set["dns:" + cn.toLowerCase()] = true; });
|
|
864
|
+
// PKCS#10 permits duplicate attribute types, so aggregate EVERY extensionRequest
|
|
865
|
+
// attribute and EVERY subjectAltName within each -- taking only the first would let
|
|
866
|
+
// a second extensionRequest smuggle identifiers past the order-set comparison.
|
|
867
|
+
(parsedCsr.attributes || []).forEach(function (a) {
|
|
868
|
+
if (a.type !== oid.byName("extensionRequest") || !Array.isArray(a.extensions)) return;
|
|
869
|
+
a.extensions.forEach(function (e) {
|
|
870
|
+
if (e.oid !== OID_SAN) return;
|
|
871
|
+
var dec = _extDecoders.byOid[OID_SAN](e.value, _extCtx);
|
|
872
|
+
(dec.names || []).forEach(function (n) {
|
|
873
|
+
if (n.tagNumber === 2) set["dns:" + String(n.value).toLowerCase()] = true;
|
|
874
|
+
else if (n.tagNumber === 7) { var t = _ipBytesToText(n.value); if (t) set["ip:" + t] = true; }
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
return set;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function _orderIdentifierSet(identifiers) {
|
|
882
|
+
var set = {};
|
|
883
|
+
identifiers.forEach(function (id) {
|
|
884
|
+
if (id.type === "dns") set["dns:" + id.value.toLowerCase()] = true;
|
|
885
|
+
else if (id.type === "ip") set["ip:" + id.value] = true;
|
|
886
|
+
});
|
|
887
|
+
return set;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function _assertCsrIdentifiers(parsedCsr, identifiers) {
|
|
891
|
+
var have = _csrIdentifierSet(parsedCsr);
|
|
892
|
+
var want = _orderIdentifierSet(identifiers);
|
|
893
|
+
var haveKeys = Object.keys(have), wantKeys = Object.keys(want);
|
|
894
|
+
var mismatch = haveKeys.length !== wantKeys.length ||
|
|
895
|
+
wantKeys.some(function (k) { return !have[k]; }) ||
|
|
896
|
+
haveKeys.some(function (k) { return !want[k]; });
|
|
897
|
+
if (mismatch) {
|
|
898
|
+
throw E("acme/csr-identifier-mismatch", "the finalize CSR identifier set " + JSON.stringify(haveKeys.sort()) +
|
|
899
|
+
" does not equal the order identifiers " + JSON.stringify(wantKeys.sort()) + " (RFC 8555 sec. 7.4)");
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* @primitive pki.acme.finalize
|
|
905
|
+
* @signature pki.acme.finalize(opts) -> Promise<object>
|
|
906
|
+
* @since 0.1.25
|
|
907
|
+
* @status experimental
|
|
908
|
+
* @spec RFC 8555
|
|
909
|
+
* @related pki.acme.newOrder
|
|
910
|
+
*
|
|
911
|
+
* Build a finalize request (RFC 8555 sec. 7.4): a kid-signed JWS whose payload
|
|
912
|
+
* `csr` is the base64url of the DER PKCS#10 (never PEM). The CSR is parsed with
|
|
913
|
+
* `pki.schema.csr.parse`; its requested identifier set (SAN + CN) MUST equal the
|
|
914
|
+
* order identifiers (`acme/csr-identifier-mismatch`), and its public key MUST NOT
|
|
915
|
+
* be the account key (`acme/key-reuse`, sec. 11.1). `opts` = `{ key, alg, nonce,
|
|
916
|
+
* url, kid, csr (DER Buffer), identifiers?, accountJwk? }`.
|
|
917
|
+
*
|
|
918
|
+
* @example
|
|
919
|
+
* await pki.acme.finalize({ key, alg: "ES256", nonce, url, kid, csr: csrDer, identifiers, accountJwk });
|
|
920
|
+
*/
|
|
921
|
+
async function finalize(o) {
|
|
922
|
+
if (!_isObject(o) || !Buffer.isBuffer(o.csr)) throw E("acme/bad-input", "finalize requires a DER CSR Buffer (opts.csr)");
|
|
923
|
+
var parsed = csr.parse(o.csr); // strict DER; rejects PEM/garbage
|
|
924
|
+
if (o.accountJwk !== undefined) {
|
|
925
|
+
var accountSpki = await _jwkToSpki(o.accountJwk);
|
|
926
|
+
if (parsed.subjectPublicKeyInfo && Buffer.isBuffer(parsed.subjectPublicKeyInfo.bytes) &&
|
|
927
|
+
parsed.subjectPublicKeyInfo.bytes.equals(accountSpki)) {
|
|
928
|
+
throw E("acme/key-reuse", "the finalize CSR public key must not be the account key (RFC 8555 sec. 11.1)");
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (o.identifiers !== undefined) {
|
|
932
|
+
if (!Array.isArray(o.identifiers) || o.identifiers.length === 0) throw E("acme/bad-input", "finalize identifiers must be the non-empty order identifier array");
|
|
933
|
+
o.identifiers.forEach(_validateOrderIdentifier); // caller-supplied -> validate before comparing (no raw TypeError)
|
|
934
|
+
_assertCsrIdentifiers(parsed, o.identifiers);
|
|
935
|
+
}
|
|
936
|
+
var payload = { csr: jose.base64url.encode(o.csr) };
|
|
937
|
+
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* @primitive pki.acme.challengeResponse
|
|
942
|
+
* @signature pki.acme.challengeResponse(opts) -> Promise<object>
|
|
943
|
+
* @since 0.1.25
|
|
944
|
+
* @status experimental
|
|
945
|
+
* @spec RFC 8555
|
|
946
|
+
* @related pki.acme.http01, pki.acme.dns01
|
|
947
|
+
*
|
|
948
|
+
* Build a challenge-response POST (RFC 8555 sec. 7.5.1): a kid-signed JWS whose
|
|
949
|
+
* payload is the type-defined response object -- `{}` for the three registered
|
|
950
|
+
* challenge types (http-01 / dns-01 / tls-alpn-01), which is DISTINCT from a
|
|
951
|
+
* POST-as-GET empty payload. `opts` = `{ key, alg, nonce, url, kid, payload? }`
|
|
952
|
+
* (payload default `{}`; pass a custom object for a future challenge type).
|
|
953
|
+
*
|
|
954
|
+
* @example
|
|
955
|
+
* await pki.acme.challengeResponse({ key, alg: "ES256", nonce, url: challUrl, kid });
|
|
956
|
+
*/
|
|
957
|
+
function challengeResponse(o) {
|
|
958
|
+
if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
|
|
959
|
+
var payload = o.payload !== undefined ? o.payload : {};
|
|
960
|
+
if (!_isObject(payload)) throw E("acme/bad-input", "a challenge response payload must be a JSON object (RFC 8555 sec. 7.5.1)");
|
|
961
|
+
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* @primitive pki.acme.deactivate
|
|
966
|
+
* @signature pki.acme.deactivate(opts) -> Promise<object>
|
|
967
|
+
* @since 0.1.25
|
|
968
|
+
* @status experimental
|
|
969
|
+
* @spec RFC 8555
|
|
970
|
+
* @related pki.acme.validate
|
|
971
|
+
*
|
|
972
|
+
* Build a deactivation POST (RFC 8555 sec. 7.3.6 account / sec. 7.5.2
|
|
973
|
+
* authorization): a kid-signed JWS with the payload `{"status":"deactivated"}` --
|
|
974
|
+
* the only client-settable status. `opts` = `{ key, alg, nonce, url, kid }`.
|
|
975
|
+
*
|
|
976
|
+
* @example
|
|
977
|
+
* await pki.acme.deactivate({ key, alg: "ES256", nonce, url: authzUrl, kid });
|
|
978
|
+
*/
|
|
979
|
+
function deactivate(o) {
|
|
980
|
+
if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
|
|
981
|
+
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, { status: "deactivated" });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* @primitive pki.acme.revokeCert
|
|
986
|
+
* @signature pki.acme.revokeCert(opts) -> Promise<object>
|
|
987
|
+
* @since 0.1.25
|
|
988
|
+
* @status experimental
|
|
989
|
+
* @spec RFC 8555, RFC 5280
|
|
990
|
+
* @related pki.acme.ariCertId
|
|
991
|
+
*
|
|
992
|
+
* Build a revokeCert request (RFC 8555 sec. 7.6): a JWS whose payload `certificate`
|
|
993
|
+
* is the base64url of the DER certificate and optional `reason` is an assigned
|
|
994
|
+
* RFC 5280 CRLReason (0-6, 8-10; 7 is unassigned). Signed EITHER by the account key (`kid` mode) OR by the
|
|
995
|
+
* certificate key (`jwk` mode) -- pass exactly one. `opts` = `{ key, alg, nonce,
|
|
996
|
+
* url, certificate (DER Buffer), reason?, kid? | jwk? }`.
|
|
997
|
+
*
|
|
998
|
+
* @example
|
|
999
|
+
* await pki.acme.revokeCert({ key, alg: "ES256", nonce, url, kid, certificate: certDer, reason: 1 });
|
|
1000
|
+
*/
|
|
1001
|
+
function revokeCert(o) {
|
|
1002
|
+
if (!_isObject(o) || !Buffer.isBuffer(o.certificate)) throw E("acme/bad-input", "revokeCert requires a DER certificate Buffer (opts.certificate)");
|
|
1003
|
+
x509.parse(o.certificate); // structural validation of the target
|
|
1004
|
+
var hasKid = Object.prototype.hasOwnProperty.call(o, "kid");
|
|
1005
|
+
var hasJwk = Object.prototype.hasOwnProperty.call(o, "jwk");
|
|
1006
|
+
if (hasKid === hasJwk) throw E("acme/bad-input", "revokeCert must be signed with EXACTLY ONE of the account kid or the certificate jwk (RFC 8555 sec. 7.6)");
|
|
1007
|
+
var payload = { certificate: jose.base64url.encode(o.certificate) };
|
|
1008
|
+
if (o.reason !== undefined) {
|
|
1009
|
+
if (typeof o.reason !== "number" || !isFinite(o.reason) || Math.floor(o.reason) !== o.reason || CRL_REASONS.indexOf(o.reason) === -1) {
|
|
1010
|
+
throw E("acme/bad-revocation-reason", "reason must be an assigned RFC 5280 CRLReason (0-6, 8-10; value 7 is unassigned)");
|
|
1011
|
+
}
|
|
1012
|
+
payload.reason = o.reason;
|
|
1013
|
+
}
|
|
1014
|
+
var header = { alg: o.alg, nonce: o.nonce, url: o.url };
|
|
1015
|
+
if (hasKid) header.kid = o.kid; else header.jwk = o.jwk;
|
|
1016
|
+
return jose.sign({ protected: header, payload: _payloadBuf(payload), key: o.key, jwk: o.jwk, profile: "acme-outer" });
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* @primitive pki.acme.keyChange
|
|
1021
|
+
* @signature pki.acme.keyChange(opts) -> Promise<object>
|
|
1022
|
+
* @since 0.1.25
|
|
1023
|
+
* @status experimental
|
|
1024
|
+
* @spec RFC 8555
|
|
1025
|
+
* @related pki.acme.newAccount
|
|
1026
|
+
*
|
|
1027
|
+
* Build a key-change request (RFC 8555 sec. 7.3.5): a nested JWS. The INNER JWS is
|
|
1028
|
+
* signed by the NEW account key (embedded `jwk`, no nonce, `url` == the keyChange
|
|
1029
|
+
* URL) over `{ account, oldKey }`; the OUTER JWS is the account (`kid`, `oldKey`)
|
|
1030
|
+
* signing that inner object. `opts` = `{ key (old private), alg (old), kid
|
|
1031
|
+
* (account URL), account (account URL), oldKey (old public JWK), newKey (new
|
|
1032
|
+
* private), newJwk (new public JWK), newAlg, nonce, url }`.
|
|
1033
|
+
*
|
|
1034
|
+
* @example
|
|
1035
|
+
* await pki.acme.keyChange({ key: oldKey, alg: "ES256", kid, account: kid, oldKey: oldJwk, newKey, newJwk, newAlg: "ES256", nonce, url });
|
|
1036
|
+
*/
|
|
1037
|
+
async function keyChange(o) {
|
|
1038
|
+
if (!_isObject(o)) throw E("acme/bad-input", "a keyChange options object is required");
|
|
1039
|
+
if (!_isString(o.account)) throw E("acme/bad-input", "keyChange requires the account URL (payload account)");
|
|
1040
|
+
if (!_isObject(o.oldKey)) throw E("acme/bad-input", "keyChange requires the old account public jwk (payload oldKey)");
|
|
1041
|
+
if (!_isObject(o.newJwk)) throw E("acme/bad-input", "keyChange requires the new account public jwk (inner header jwk)");
|
|
1042
|
+
// oldKey is published in the inner payload and newJwk in the inner header -> both public-only.
|
|
1043
|
+
jose.assertPublicJwk(o.oldKey);
|
|
1044
|
+
jose.assertPublicJwk(o.newJwk);
|
|
1045
|
+
var inner = await jose.sign({
|
|
1046
|
+
protected: { alg: o.newAlg, url: o.url, jwk: o.newJwk },
|
|
1047
|
+
payload: _payloadBuf({ account: o.account, oldKey: o.oldKey }),
|
|
1048
|
+
key: o.newKey, jwk: o.newJwk, profile: "keychange-inner",
|
|
1049
|
+
});
|
|
1050
|
+
return jose.sign({ protected: { alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload: _payloadBuf(inner), key: o.key, profile: "acme-outer" });
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ---- ARI (RFC 9773 renewal information) ----------------------------------
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* @primitive pki.acme.ariCertId
|
|
1057
|
+
* @signature pki.acme.ariCertId(certDer) -> string
|
|
1058
|
+
* @since 0.1.25
|
|
1059
|
+
* @status experimental
|
|
1060
|
+
* @spec RFC 9773
|
|
1061
|
+
* @related pki.acme.parseAriCertId, pki.acme.newOrder
|
|
1062
|
+
*
|
|
1063
|
+
* The RFC 9773 sec. 4.1 ARI certificate identifier of a DER certificate:
|
|
1064
|
+
* `base64url(AKI keyIdentifier) || '.' || base64url(serial content octets)`. The
|
|
1065
|
+
* serial is the raw DER INTEGER content -- its leading `00` sign-padding byte is
|
|
1066
|
+
* PRESERVED (dropping it is the documented mass-404 client bug). Throws
|
|
1067
|
+
* `acme/bad-certid` if the certificate lacks an AKI keyIdentifier.
|
|
1068
|
+
*
|
|
1069
|
+
* @example
|
|
1070
|
+
* pki.acme.ariCertId(certDer); // -> "<b64u-aki>.<b64u-serial>"
|
|
1071
|
+
*/
|
|
1072
|
+
function ariCertId(certDer) {
|
|
1073
|
+
if (!Buffer.isBuffer(certDer)) throw E("acme/bad-input", "ariCertId requires a DER certificate Buffer");
|
|
1074
|
+
var cert = x509.parse(certDer);
|
|
1075
|
+
var akiExt = (cert.extensions || []).filter(function (e) { return e.oid === OID_AKI; })[0];
|
|
1076
|
+
if (!akiExt) throw E("acme/bad-certid", "the certificate has no authorityKeyIdentifier extension (RFC 9773 sec. 4.1)");
|
|
1077
|
+
var aki = _extDecoders.byOid[OID_AKI](akiExt.value, _extCtx);
|
|
1078
|
+
if (!aki || !Buffer.isBuffer(aki.keyIdentifier)) throw E("acme/bad-certid", "the authorityKeyIdentifier has no keyIdentifier field (RFC 9773 sec. 4.1)");
|
|
1079
|
+
var serialBytes = Buffer.from(cert.serialNumberHex, "hex"); // DER INTEGER content; sign-pad preserved
|
|
1080
|
+
return jose.base64url.encode(aki.keyIdentifier) + "." + jose.base64url.encode(serialBytes);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* @primitive pki.acme.parseAriCertId
|
|
1085
|
+
* @signature pki.acme.parseAriCertId(certId) -> { keyIdentifier, serial }
|
|
1086
|
+
* @since 0.1.25
|
|
1087
|
+
* @status experimental
|
|
1088
|
+
* @spec RFC 9773
|
|
1089
|
+
* @related pki.acme.ariCertId
|
|
1090
|
+
*
|
|
1091
|
+
* Parse an ARI certID string (RFC 9773 sec. 4.1) into `{ keyIdentifier, serial }`
|
|
1092
|
+
* Buffers. The two dot-joined halves are each strict base64url (padding /
|
|
1093
|
+
* non-alphabet rejected); anything but exactly two parts throws `acme/bad-certid`.
|
|
1094
|
+
*
|
|
1095
|
+
* @example
|
|
1096
|
+
* pki.acme.parseAriCertId("<b64u-aki>.<b64u-serial>").serial; // -> Buffer
|
|
1097
|
+
*/
|
|
1098
|
+
function parseAriCertId(certId) {
|
|
1099
|
+
if (!_isString(certId)) throw E("acme/bad-certid", "an ARI certID must be a string");
|
|
1100
|
+
var parts = certId.split(".");
|
|
1101
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) throw E("acme/bad-certid", "an ARI certID must be two base64url halves joined by '.' (RFC 9773 sec. 4.1)");
|
|
1102
|
+
var keyIdentifier, serial;
|
|
1103
|
+
try { keyIdentifier = jose.base64url.decode(parts[0]); serial = jose.base64url.decode(parts[1]); }
|
|
1104
|
+
catch (e) { throw E("acme/bad-certid", "an ARI certID half is not strict base64url (RFC 9773 sec. 4.1)", e); }
|
|
1105
|
+
return { keyIdentifier: keyIdentifier, serial: serial };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* @primitive pki.acme.validateRenewalInfo
|
|
1110
|
+
* @signature pki.acme.validateRenewalInfo(obj) -> obj
|
|
1111
|
+
* @since 0.1.25
|
|
1112
|
+
* @status experimental
|
|
1113
|
+
* @spec RFC 9773
|
|
1114
|
+
* @related pki.acme.validate
|
|
1115
|
+
*
|
|
1116
|
+
* Validate an ARI RenewalInfo object (RFC 9773 sec. 4.2): a `suggestedWindow` with
|
|
1117
|
+
* RFC 3339 `start` and `end`, `end` strictly after `start` (an inverted or
|
|
1118
|
+
* zero-width window throws `acme/bad-renewal-window` -- the client treats it as no
|
|
1119
|
+
* response, defusing a renewal stampede), and an optional `explanationURL`.
|
|
1120
|
+
* Returns the object.
|
|
1121
|
+
*
|
|
1122
|
+
* @example
|
|
1123
|
+
* pki.acme.validateRenewalInfo({ suggestedWindow: { start: "2026-01-01T00:00:00Z", end: "2026-01-08T00:00:00Z" } });
|
|
1124
|
+
*/
|
|
1125
|
+
function validateRenewalInfo(obj) {
|
|
1126
|
+
_validate("renewalInfo", obj);
|
|
1127
|
+
var w = obj.suggestedWindow;
|
|
1128
|
+
if (!_isObject(w) || !_isRfc3339(w.start) || !_isRfc3339(w.end)) throw E("acme/bad-renewal-window", "a renewalInfo suggestedWindow must carry RFC 3339 start and end (RFC 9773 sec. 4.2)");
|
|
1129
|
+
if (Date.parse(w.end) <= Date.parse(w.start)) throw E("acme/bad-renewal-window", "the renewal window end must be strictly after start (RFC 9773 sec. 4.2)");
|
|
1130
|
+
return obj;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
module.exports = {
|
|
1134
|
+
validate: validate,
|
|
1135
|
+
validateProblem: validateProblem,
|
|
1136
|
+
validateRenewalInfo: validateRenewalInfo,
|
|
1137
|
+
identify: identify,
|
|
1138
|
+
assertTransition: assertTransition,
|
|
1139
|
+
keyAuthorization: keyAuthorization,
|
|
1140
|
+
http01: http01,
|
|
1141
|
+
dns01: dns01,
|
|
1142
|
+
tlsAlpn01Extension: tlsAlpn01Extension,
|
|
1143
|
+
verifyTlsAlpn01: verifyTlsAlpn01,
|
|
1144
|
+
// request builders
|
|
1145
|
+
postAsGet: postAsGet,
|
|
1146
|
+
newAccount: newAccount,
|
|
1147
|
+
externalAccountBinding: externalAccountBinding,
|
|
1148
|
+
newOrder: newOrder,
|
|
1149
|
+
finalize: finalize,
|
|
1150
|
+
challengeResponse: challengeResponse,
|
|
1151
|
+
deactivate: deactivate,
|
|
1152
|
+
revokeCert: revokeCert,
|
|
1153
|
+
keyChange: keyChange,
|
|
1154
|
+
// ARI (RFC 9773)
|
|
1155
|
+
ariCertId: ariCertId,
|
|
1156
|
+
parseAriCertId: parseAriCertId,
|
|
1157
|
+
// re-exported for the ACME consumer / test surface
|
|
1158
|
+
jose: jose,
|
|
1159
|
+
};
|