@blamejs/pki 0.3.26 → 0.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +5 -6
- package/index.js +2 -2
- package/lib/acme.js +4 -12
- package/lib/cmp-build.js +3 -1
- package/lib/cmp-session.js +1045 -0
- package/lib/cmp-verify.js +23 -5
- package/lib/constants.js +4 -0
- package/lib/est.js +352 -29
- package/lib/http-digest.js +379 -0
- package/lib/http-transport.js +5 -3
- package/lib/path-validate.js +3 -0
- package/lib/sleep.js +23 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- NOT on pki.*. HTTP Digest access authentication (RFC 7616): parse an UNTRUSTED
|
|
6
|
+
// WWW-Authenticate challenge (fail-closed, quoted-string-honoring) and compute the Authorization
|
|
7
|
+
// response header (byte-exact A1/A2/KD). Prefix-agnostic: every thrown code comes from the caller's
|
|
8
|
+
// E(code, msg) FACTORY (never a defineClass class -- feedback_guard_error_factory_not_class) and its
|
|
9
|
+
// policy.codes map, so pki.est composes it now and pki.acme / pki.cmp can reuse it as config, not a fork.
|
|
10
|
+
//
|
|
11
|
+
// The challenge is attacker-shaped: a comma inside a quoted value is NOT a delimiter (the _hasBasicChallenge
|
|
12
|
+
// substring-scan bug class), a missing realm / nonce is REJECTED not defaulted, and an unsupported / weak
|
|
13
|
+
// algorithm or a no-qop challenge fails closed rather than downgrading (feedback_guards_fail_closed_not_guess).
|
|
14
|
+
|
|
15
|
+
var crypto = require("crypto");
|
|
16
|
+
var constants = require("./constants");
|
|
17
|
+
|
|
18
|
+
// Algorithm registry (a data row, not a switch -- Hard rule #2): rank orders the RFC 7616 sec. 3.7
|
|
19
|
+
// "most secure the client can use" selection (SHA-512-256 > SHA-256 > MD5); an unknown algorithm ranks 0.
|
|
20
|
+
var ALGS = {
|
|
21
|
+
"SHA-512-256": { hash: "sha512-256", rank: 3, sess: false }, "SHA-512-256-SESS": { hash: "sha512-256", rank: 3, sess: true },
|
|
22
|
+
"SHA-256": { hash: "sha256", rank: 2, sess: false }, "SHA-256-SESS": { hash: "sha256", rank: 2, sess: true },
|
|
23
|
+
"MD5": { hash: "md5", rank: 1, sess: false }, "MD5-SESS": { hash: "md5", rank: 1, sess: true },
|
|
24
|
+
};
|
|
25
|
+
var DEFAULT_CODES = {
|
|
26
|
+
unsupportedAlgorithm: "digest/unsupported-algorithm", weakAlgorithm: "digest/weak-algorithm",
|
|
27
|
+
noQop: "digest/no-qop", badChallenge: "digest/bad-challenge",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// H = lowercase-hex digest; the octet string is fed as latin1 so a UTF-8-encoded credential (charset=UTF-8)
|
|
31
|
+
// is hashed over its exact bytes. KD(secret, data) = H(secret ":" data).
|
|
32
|
+
function H(hash, s) { return crypto.createHash(hash).update(s, "latin1").digest("hex"); }
|
|
33
|
+
function KD(hash, secret, data) { return H(hash, secret + ":" + data); }
|
|
34
|
+
function _qstr(v) { return "\"" + String(v).replace(/(["\\])/g, "\\$1") + "\""; }
|
|
35
|
+
|
|
36
|
+
// Split a header at commas that are OUTSIDE a quoted-string (a quoted comma is a literal, not a delimiter).
|
|
37
|
+
function _commaSplitOutsideQuotes(s) {
|
|
38
|
+
var out = [], buf = "", inQ = false, esc = false;
|
|
39
|
+
for (var i = 0; i < s.length; i++) {
|
|
40
|
+
var c = s.charAt(i);
|
|
41
|
+
if (esc) { buf += c; esc = false; continue; }
|
|
42
|
+
if (inQ && c === "\\") { buf += c; esc = true; continue; }
|
|
43
|
+
if (c === "\"") { inQ = !inQ; buf += c; continue; }
|
|
44
|
+
if (c === "," && !inQ) { out.push(buf); buf = ""; continue; }
|
|
45
|
+
buf += c;
|
|
46
|
+
}
|
|
47
|
+
out.push(buf);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
// Index of the first '=' OUTSIDE a quoted-string, or -1.
|
|
51
|
+
function _firstEqOutsideQuotes(s) {
|
|
52
|
+
var inQ = false, esc = false;
|
|
53
|
+
for (var i = 0; i < s.length; i++) {
|
|
54
|
+
var c = s.charAt(i);
|
|
55
|
+
if (esc) { esc = false; continue; }
|
|
56
|
+
if (inQ && c === "\\") { esc = true; continue; }
|
|
57
|
+
if (c === "\"") { inQ = !inQ; continue; }
|
|
58
|
+
if (c === "=" && !inQ) return i;
|
|
59
|
+
}
|
|
60
|
+
return -1;
|
|
61
|
+
}
|
|
62
|
+
function _unq(s) { return s.slice(1, -1).replace(/\\(.)/g, "$1"); } // only ever called on a well-formed quoted-string (see _closedQuote)
|
|
63
|
+
// A well-formed quoted-string: opens with ", closes with an UNESCAPED " that is the LAST character (nothing trails
|
|
64
|
+
// it). An unterminated / trailing-garbage quoted-string is malformed (RFC 7230 sec. 3.2.6) and rejected.
|
|
65
|
+
function _closedQuote(s) {
|
|
66
|
+
if (s.length < 2 || s.charAt(0) !== "\"") return false;
|
|
67
|
+
var esc = false;
|
|
68
|
+
for (var i = 1; i < s.length; i++) {
|
|
69
|
+
var c = s.charAt(i);
|
|
70
|
+
if (esc) { esc = false; continue; }
|
|
71
|
+
if (c === "\\") { esc = true; continue; }
|
|
72
|
+
if (c === "\"") return i === s.length - 1;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
// A control octet (RFC 7230 sec. 3.2.6 quoted-string / qdtext forbids CTL except HTAB). Scanned with charCodeAt
|
|
77
|
+
// rather than a control-char regex literal (which eslint no-control-regex correctly refuses).
|
|
78
|
+
function _hasCtl(s) {
|
|
79
|
+
for (var i = 0; i < s.length; i++) { var c = s.charCodeAt(i); if ((c < 0x20 && c !== 0x09) || c === 0x7f) return true; }
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Split a WWW-Authenticate header into [{ scheme, paramText }]. A comma-separated segment whose pre-'='
|
|
84
|
+
// text is "token WS token" (a scheme name before the first auth-param key), or a bare token with no '=',
|
|
85
|
+
// STARTS a new challenge; a "key=value" segment is a param continuation of the current challenge.
|
|
86
|
+
function _splitChallenges(s) {
|
|
87
|
+
var segs = _commaSplitOutsideQuotes(s);
|
|
88
|
+
var out = [], cur = null;
|
|
89
|
+
for (var i = 0; i < segs.length; i++) {
|
|
90
|
+
var seg = segs[i].replace(/^\s+|\s+$/g, "");
|
|
91
|
+
if (seg === "") continue;
|
|
92
|
+
var eq = _firstEqOutsideQuotes(seg);
|
|
93
|
+
var pre = (eq < 0 ? seg : seg.slice(0, eq)).replace(/^\s+|\s+$/g, "");
|
|
94
|
+
var ws = /^(\S+)\s+(\S[\s\S]*)$/.exec(pre);
|
|
95
|
+
if (eq < 0) { cur = { scheme: seg, paramText: "" }; out.push(cur); } // a bare scheme token (e.g. "Basic")
|
|
96
|
+
else if (ws) { cur = { scheme: ws[1], paramText: seg.replace(/^\s*\S+\s+/, "") }; out.push(cur); } // "scheme firstKey=..."
|
|
97
|
+
else if (cur) { cur.paramText = cur.paramText ? cur.paramText + "," + seg : seg; } // "key=value" continuation
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
// Parse a challenge's params into { key(lower): { value, quoted } }; a repeated key is malformed (fail closed).
|
|
102
|
+
function _parseParams(paramText, E, code) {
|
|
103
|
+
var segs = _commaSplitOutsideQuotes(paramText), map = Object.create(null);
|
|
104
|
+
for (var i = 0; i < segs.length; i++) {
|
|
105
|
+
var seg = segs[i].replace(/^\s+|\s+$/g, "");
|
|
106
|
+
if (seg === "") continue;
|
|
107
|
+
var eq = _firstEqOutsideQuotes(seg);
|
|
108
|
+
if (eq < 0) throw E(code, "malformed Digest auth-param (no '='): " + JSON.stringify(seg));
|
|
109
|
+
var key = seg.slice(0, eq).replace(/^\s+|\s+$/g, "").toLowerCase();
|
|
110
|
+
var rawVal = seg.slice(eq + 1).replace(/^\s+|\s+$/g, "");
|
|
111
|
+
var quoted = rawVal.charAt(0) === "\"";
|
|
112
|
+
if (quoted && !_closedQuote(rawVal)) throw E(code, "an unterminated or trailing-garbage Digest quoted-string (RFC 7230 sec. 3.2.6)");
|
|
113
|
+
if (Object.prototype.hasOwnProperty.call(map, key)) throw E(code, "repeated Digest auth-param " + JSON.stringify(key));
|
|
114
|
+
var value = quoted ? _unq(rawVal) : rawVal;
|
|
115
|
+
// Reject a control octet in any value (CR / LF / NUL / ...): it is a MUST-reject per RFC 7230 qdtext AND a
|
|
116
|
+
// header-injection sink -- an unescaped CR/LF reflected into the outgoing Authorization would split the header.
|
|
117
|
+
if (_hasCtl(value)) throw E(code, "a Digest auth-param value contains a control character (RFC 7230 sec. 3.2.6)");
|
|
118
|
+
map[key] = { value: value, quoted: quoted };
|
|
119
|
+
}
|
|
120
|
+
return map;
|
|
121
|
+
}
|
|
122
|
+
// Structural validation of ONE Digest challenge's params (RFC 7616 sec. 3.3). Throws on any violation.
|
|
123
|
+
function _validateDigest(paramText, E, code) {
|
|
124
|
+
var p = _parseParams(paramText, E, code);
|
|
125
|
+
if (!p.realm || !p.realm.quoted || p.realm.value === "") throw E(code, "a Digest challenge requires a non-empty quoted realm (RFC 7616 sec. 3.3)");
|
|
126
|
+
if (!p.nonce || !p.nonce.quoted || p.nonce.value === "") throw E(code, "a Digest challenge requires a non-empty quoted nonce (RFC 7616 sec. 3.3)");
|
|
127
|
+
if (p.algorithm && p.algorithm.quoted) throw E(code, "the Digest algorithm must be a token, not a quoted-string (RFC 7616 sec. 3.3)");
|
|
128
|
+
var qop = [];
|
|
129
|
+
if (p.qop) {
|
|
130
|
+
if (!p.qop.quoted) throw E(code, "the Digest qop must be a quoted list (RFC 7616 sec. 3.3)");
|
|
131
|
+
qop = p.qop.value.split(",").map(function (x) { return x.replace(/^\s+|\s+$/g, "").toLowerCase(); }).filter(Boolean);
|
|
132
|
+
// A qop directive that is PRESENT but lists no value (qop="" / qop=", ,") is malformed -- it is NOT the
|
|
133
|
+
// absent-qop (RFC 2069) case. Reject it rather than silently collapsing to a no-qop response the server
|
|
134
|
+
// cannot accept (RFC 7616 sec. 3.3). Only an OMITTED qop directive selects the legacy no-qop path.
|
|
135
|
+
if (qop.length === 0) throw E(code, "a present Digest qop directive must list at least one value (RFC 7616 sec. 3.3)");
|
|
136
|
+
}
|
|
137
|
+
// domain (RFC 7616 sec. 3.3): a QUOTED, space-separated list of URIs defining the protection space. An
|
|
138
|
+
// UNQUOTED domain is malformed and rejected (fail closed like realm / nonce / qop) rather than silently
|
|
139
|
+
// widened -- widening a mis-encoded scope to "the whole server" would send credentials MORE broadly than the
|
|
140
|
+
// server intended. Surfaced as an array of the listed URIs, or null when omitted / quoted-empty -- which per
|
|
141
|
+
// the RFC means "all URIs on the responding server" (the answer may be reused for any same-origin URI, sec. 3.5).
|
|
142
|
+
if (p.domain && !p.domain.quoted) throw E(code, "the Digest domain must be a quoted-string (RFC 7616 sec. 3.3)");
|
|
143
|
+
var domain = (p.domain && p.domain.value.replace(/^\s+|\s+$/g, "") !== "")
|
|
144
|
+
? p.domain.value.replace(/^\s+|\s+$/g, "").split(/\s+/) : null;
|
|
145
|
+
// charset (RFC 7616 sec. 3.3): the ONLY permitted value is the UNQUOTED token "UTF-8". A quoted charset, or any
|
|
146
|
+
// other value, is malformed -- answering it would hash the credentials in the wrong encoding -- so it is
|
|
147
|
+
// rejected (and thus skipped during multi-offer selection in favour of a conforming offer).
|
|
148
|
+
if (p.charset && (p.charset.quoted || String(p.charset.value).toUpperCase() !== "UTF-8")) throw E(code, "the Digest charset must be the unquoted token UTF-8 (RFC 7616 sec. 3.3)");
|
|
149
|
+
// stale (RFC 7616 sec. 3.3) is the unquoted token "true" or "false". A quoted or otherwise invalid value
|
|
150
|
+
// (stale=maybe) is malformed and rejected (thus skipped in selection) rather than parsed as a false flag that
|
|
151
|
+
// could shadow a conforming offer.
|
|
152
|
+
if (p.stale) {
|
|
153
|
+
var sv = String(p.stale.value).toLowerCase();
|
|
154
|
+
if (p.stale.quoted || (sv !== "true" && sv !== "false")) throw E(code, "the Digest stale directive must be the unquoted token true or false (RFC 7616 sec. 3.3)");
|
|
155
|
+
}
|
|
156
|
+
// userhash (RFC 7616 sec. 3.3) is likewise the unquoted token "true" or "false". A quoted or otherwise invalid
|
|
157
|
+
// value is malformed and rejected (skipped in selection) rather than silently changing how the username is sent.
|
|
158
|
+
if (p.userhash) {
|
|
159
|
+
var uhv = String(p.userhash.value).toLowerCase();
|
|
160
|
+
if (p.userhash.quoted || (uhv !== "true" && uhv !== "false")) throw E(code, "the Digest userhash directive must be the unquoted token true or false (RFC 7616 sec. 3.3)");
|
|
161
|
+
}
|
|
162
|
+
// opaque (RFC 7616 sec. 3.3) is a quoted-string echoed back verbatim. An UNQUOTED opaque is malformed and
|
|
163
|
+
// rejected (skipped in selection) rather than accepted, where it could shadow a conforming offer.
|
|
164
|
+
if (p.opaque && !p.opaque.quoted) throw E(code, "the Digest opaque directive must be a quoted-string (RFC 7616 sec. 3.3)");
|
|
165
|
+
return {
|
|
166
|
+
scheme: "Digest", realm: p.realm.value, nonce: p.nonce.value, qop: qop, domain: domain,
|
|
167
|
+
algorithm: p.algorithm ? p.algorithm.value.toUpperCase() : "MD5",
|
|
168
|
+
opaque: p.opaque ? p.opaque.value : null,
|
|
169
|
+
stale: !!(p.stale && String(p.stale.value).toLowerCase() === "true"),
|
|
170
|
+
userhash: !!(p.userhash && String(p.userhash.value).toLowerCase() === "true"),
|
|
171
|
+
charset: p.charset ? p.charset.value : null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// parseChallenge(www, E, code, policy?) -> the most-secure USABLE structurally-valid Digest challenge,
|
|
176
|
+
// OR null (no Digest challenge present), OR throw E(code) (a Digest challenge present but none valid).
|
|
177
|
+
// Selection is policy-aware: a challenge answer() would refuse under the active policy (an unsupported /
|
|
178
|
+
// MD5-when-disallowed algorithm, or a qop this client cannot satisfy) is ranked BELOW every usable one, so a
|
|
179
|
+
// higher-algorithm-rank but unusable offer (e.g. a SHA-512-256 no-qop challenge under the default policy)
|
|
180
|
+
// never shadows a lower-ranked usable one (e.g. SHA-256 with qop="auth") -- RFC 7616 sec. 3.3. When NO
|
|
181
|
+
// challenge is usable the highest-algorithm-rank one is still returned, so answer() reports the specific
|
|
182
|
+
// policy reason (which opt to set) rather than a generic "no challenge".
|
|
183
|
+
function parseChallenge(www, E, code, policy) {
|
|
184
|
+
var pol = policy || {};
|
|
185
|
+
var codes = pol.codes || DEFAULT_CODES;
|
|
186
|
+
// preferStale marks a CREDENTIAL-REJECTION context (a 401 answering a credentialed request in the same space):
|
|
187
|
+
// there, a stale=true offer is RETRYABLE with a fresh nonce while a stale=false offer is a terminal rejection,
|
|
188
|
+
// so a retryable offer must outrank a stronger non-retryable one (RFC 7616 sec. 3.3). In the initial context
|
|
189
|
+
// it is not set and stale plays no part.
|
|
190
|
+
var preferStale = !!pol.preferStale;
|
|
191
|
+
var raw = String(www == null ? "" : www);
|
|
192
|
+
if (raw.length > constants.LIMITS.HTTP_AUTH_HEADER_MAX_BYTES) throw E(code, "the WWW-Authenticate header exceeds the " + constants.LIMITS.HTTP_AUTH_HEADER_MAX_BYTES + "-byte cap");
|
|
193
|
+
var challenges = _splitChallenges(raw);
|
|
194
|
+
var best = null, bestUsable = false, bestApplicable = false, bestStale = false, bestRank = -1, sawDigest = false;
|
|
195
|
+
for (var i = 0; i < challenges.length; i++) {
|
|
196
|
+
if (challenges[i].scheme.toLowerCase() !== "digest") continue;
|
|
197
|
+
sawDigest = true;
|
|
198
|
+
var parsed;
|
|
199
|
+
// A MALFORMED offer is skipped, not fatal: parseChallenge's contract is to throw only when NO valid Digest
|
|
200
|
+
// offer exists (RFC 7616 sec. 3.3), so a bad offer alongside a good one still authenticates on the good one.
|
|
201
|
+
try { parsed = _validateDigest(challenges[i].paramText, E, code); }
|
|
202
|
+
catch (_e) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
var alg = ALGS[parsed.algorithm];
|
|
206
|
+
var rank = alg ? alg.rank : 0;
|
|
207
|
+
var usable = _rejection(parsed, pol, codes) === null;
|
|
208
|
+
// APPLICABLE: the offer's own protection space covers the CURRENT request target (RFC 7616 sec. 3.5). An
|
|
209
|
+
// offer whose `domain` excludes this request cannot authenticate it (the caller would omit the credential),
|
|
210
|
+
// so it must not shadow a weaker offer that does apply. Only checked when the caller supplies the request
|
|
211
|
+
// context; otherwise every offer is treated as applicable.
|
|
212
|
+
var applicable = pol.requestTarget === undefined ? true : inProtectionSpace(parsed, pol.requestOrigin, pol.requestTarget);
|
|
213
|
+
// A RETRYABLE offer (only meaningful in the rejection context) is stale=true, carries a FRESH nonce, AND is
|
|
214
|
+
// for the SAME realm whose credential was just rejected -- the conditions under which the caller re-answers.
|
|
215
|
+
// A stale offer for a DIFFERENT realm is a new protection space (not a retry), and a stale offer repeating
|
|
216
|
+
// the prior nonce cannot re-answer, so neither must out-rank a weaker genuinely retryable offer (RFC 7616 sec. 3.3).
|
|
217
|
+
var retryable = preferStale && !!parsed.stale && parsed.nonce !== pol.priorNonce && parsed.realm === pol.priorRealm;
|
|
218
|
+
// Usability dominates; then an offer that APPLIES to this request; then (in a rejection) a retryable offer;
|
|
219
|
+
// then the stronger algorithm.
|
|
220
|
+
if (best === null ||
|
|
221
|
+
(usable && !bestUsable) ||
|
|
222
|
+
(usable === bestUsable && applicable && !bestApplicable) ||
|
|
223
|
+
(usable === bestUsable && applicable === bestApplicable && retryable && !bestStale) ||
|
|
224
|
+
(usable === bestUsable && applicable === bestApplicable && retryable === bestStale && rank > bestRank)) {
|
|
225
|
+
best = parsed; bestUsable = usable; bestApplicable = applicable; bestStale = retryable; bestRank = rank;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (best) return best;
|
|
229
|
+
if (sawDigest) throw E(code, "no valid Digest challenge: every Digest offer was malformed (missing realm / nonce or a bad directive, RFC 7616 sec. 3.3)");
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Credential octets for the given charset (RFC 7616 sec. 4): UTF-8 -> the exact UTF-8 bytes; else the
|
|
234
|
+
// string as-is (ASCII / latin1). H() hashes over latin1, so a UTF-8 credential is fed as its octet string.
|
|
235
|
+
function _octets(s, charset) {
|
|
236
|
+
var v = s == null ? "" : String(s);
|
|
237
|
+
return (charset && String(charset).toUpperCase() === "UTF-8") ? Buffer.from(v, "utf8").toString("latin1") : v;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Does the string carry any non-ASCII code unit (> U+007F)? Such a UTF-8 username is sent via `username*`.
|
|
241
|
+
function _hasNonAscii(s) {
|
|
242
|
+
s = String(s == null ? "" : s);
|
|
243
|
+
for (var i = 0; i < s.length; i++) { if (s.charCodeAt(i) > 0x7F) return true; }
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// RFC 5987 / RFC 8187 percent-encoding of a string's UTF-8 octets: attr-char (ALPHA / DIGIT / "!#$&+-.^_`|~")
|
|
248
|
+
// is kept literal, every other octet is %XX (upper-hex). Used for the Digest `username*` extended value.
|
|
249
|
+
function _pctEncodeUtf8(s) {
|
|
250
|
+
var bytes = Buffer.from(String(s == null ? "" : s), "utf8");
|
|
251
|
+
var out = "";
|
|
252
|
+
for (var i = 0; i < bytes.length; i++) {
|
|
253
|
+
var b = bytes[i];
|
|
254
|
+
if ((b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A) ||
|
|
255
|
+
b === 0x21 || b === 0x23 || b === 0x24 || b === 0x26 || b === 0x2B || b === 0x2D ||
|
|
256
|
+
b === 0x2E || b === 0x5E || b === 0x5F || b === 0x60 || b === 0x7C || b === 0x7E) {
|
|
257
|
+
out += String.fromCharCode(b);
|
|
258
|
+
} else {
|
|
259
|
+
out += "%" + (b < 0x10 ? "0" : "") + b.toString(16).toUpperCase();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The policy gates a parsed challenge must clear to be answerable (RFC 7616 sec. 3.4): a supported
|
|
266
|
+
// algorithm, MD5 only when allowed, and a qop this client can satisfy (auth / auth-int, or no-qop only when
|
|
267
|
+
// allowLegacyQop). Returns a { code, msg } rejection or null. This is the SINGLE definition both answer()
|
|
268
|
+
// and parseChallenge() consult, so challenge SELECTION can never rank a challenge as usable that answer()
|
|
269
|
+
// would then refuse (or vice-versa) -- the two cannot drift.
|
|
270
|
+
function _rejection(challenge, pol, codes) {
|
|
271
|
+
var alg = ALGS[challenge.algorithm];
|
|
272
|
+
if (!alg) return { code: codes.unsupportedAlgorithm, msg: "unsupported Digest algorithm " + challenge.algorithm + " (supported: SHA-512-256, SHA-256, MD5)" };
|
|
273
|
+
if (alg.hash === "md5" && !pol.allowMD5) return { code: codes.weakAlgorithm, msg: "MD5 Digest refused by default (RFC 7616 sec. 3.4 discourages it); set opts.auth.allowMD5 for legacy interop" };
|
|
274
|
+
if (challenge.qop.length === 0) {
|
|
275
|
+
if (!pol.allowLegacyQop) return { code: codes.noQop, msg: "a no-qop (RFC 2069) Digest challenge is refused by default; set opts.auth.allowLegacyQop for legacy interop" };
|
|
276
|
+
} else if (challenge.qop.indexOf("auth") === -1 && challenge.qop.indexOf("auth-int") === -1) {
|
|
277
|
+
return { code: codes.badChallenge, msg: "the Digest qop offered no member this client supports (auth / auth-int)" };
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// answer(challenge, { method, uri, username, password, body, policy, rng }, E) -> the Authorization header value.
|
|
283
|
+
function answer(challenge, params, E) {
|
|
284
|
+
var pol = params.policy || {};
|
|
285
|
+
var codes = pol.codes || DEFAULT_CODES;
|
|
286
|
+
var rej = _rejection(challenge, pol, codes);
|
|
287
|
+
if (rej) throw E(rej.code, rej.msg);
|
|
288
|
+
var alg = ALGS[challenge.algorithm];
|
|
289
|
+
var useQop = challenge.qop.indexOf("auth") !== -1 ? "auth" : (challenge.qop.indexOf("auth-int") !== -1 ? "auth-int" : null);
|
|
290
|
+
var cnonce = String((params.rng || function () { return crypto.randomBytes(18).toString("base64"); })());
|
|
291
|
+
// The realm is used with its incoming header OCTETS unchanged (RFC 7616 sec. 4). The realm arrives from the
|
|
292
|
+
// wire, so a transport that exposes header bytes as Latin-1 (the Node default) already presents the server's
|
|
293
|
+
// UTF-8 octets one-per-char; re-encoding them as UTF-8 (as user / pass, which come from the caller as Unicode
|
|
294
|
+
// strings, correctly do) would DOUBLE-encode and make the digest disagree with the server. H() hashes over
|
|
295
|
+
// Latin-1, so the raw realm string contributes exactly the octets the server used.
|
|
296
|
+
var realm = challenge.realm, nonce = challenge.nonce;
|
|
297
|
+
// Normalize UTF-8 credentials to NFC before hashing (RFC 7616 sec. 4): a server hashes the normalized form,
|
|
298
|
+
// so a canonically-decomposed username / password (e + combining accent vs the precomposed char) must be
|
|
299
|
+
// composed first or HA1 disagrees. The SAME normalized username feeds A1, userhash, and username*.
|
|
300
|
+
var isUtf8 = String(challenge.charset || "").toUpperCase() === "UTF-8";
|
|
301
|
+
var pUser = params.username == null ? "" : String(params.username);
|
|
302
|
+
var pPass = params.password == null ? "" : String(params.password);
|
|
303
|
+
if (isUtf8) { pUser = pUser.normalize("NFC"); pPass = pPass.normalize("NFC"); }
|
|
304
|
+
var user = _octets(pUser, challenge.charset);
|
|
305
|
+
var pass = _octets(pPass, challenge.charset);
|
|
306
|
+
var HA1 = H(alg.hash, user + ":" + realm + ":" + pass);
|
|
307
|
+
if (alg.sess) HA1 = H(alg.hash, HA1 + ":" + nonce + ":" + cnonce);
|
|
308
|
+
var A2 = (useQop === "auth-int")
|
|
309
|
+
? params.method + ":" + params.uri + ":" + H(alg.hash, params.body == null ? "" : params.body)
|
|
310
|
+
: params.method + ":" + params.uri;
|
|
311
|
+
var HA2 = H(alg.hash, A2);
|
|
312
|
+
// The nonce-count: a (nonce, nc) pair MUST NOT be reused (RFC 7616 sec. 3.4), so a request that REUSES a nonce
|
|
313
|
+
// (a same-origin redirect, an auth-int retry) increments nc; a fresh nonce restarts at 1. The caller supplies
|
|
314
|
+
// the count for this nonce (default 1); formatted as 8 lowercase hex.
|
|
315
|
+
var ncNum = (typeof params.nc === "number" && params.nc >= 1) ? Math.floor(params.nc) : 1;
|
|
316
|
+
var nc = ("0000000" + ncNum.toString(16)).slice(-8);
|
|
317
|
+
var response = useQop
|
|
318
|
+
? KD(alg.hash, HA1, nonce + ":" + nc + ":" + cnonce + ":" + useQop + ":" + HA2)
|
|
319
|
+
: KD(alg.hash, HA1, nonce + ":" + HA2);
|
|
320
|
+
// The username field (RFC 7616 sec. 3.4). userhash=true sends H(username:realm) for privacy (sec. 3.4.4) --
|
|
321
|
+
// an ASCII hex value; A1 above still used the REAL username. Otherwise a charset=UTF-8 username containing
|
|
322
|
+
// non-ASCII characters MUST be carried in the extended `username*` form (RFC 5987 / RFC 8187 percent-encoded
|
|
323
|
+
// UTF-8), because a server reads the legacy quoted `username` as ISO-8859-1 and could not resolve the account;
|
|
324
|
+
// `username` and `username*` MUST NOT both appear. An ASCII (or userhash) username uses the legacy quoted form.
|
|
325
|
+
var parts;
|
|
326
|
+
if (!challenge.userhash && isUtf8 && _hasNonAscii(pUser)) {
|
|
327
|
+
parts = ["username*=UTF-8''" + _pctEncodeUtf8(pUser)];
|
|
328
|
+
} else {
|
|
329
|
+
var sentUser = challenge.userhash ? H(alg.hash, _octets(pUser, challenge.charset) + ":" + realm) : _octets(pUser, challenge.charset);
|
|
330
|
+
parts = ["username=" + _qstr(sentUser)];
|
|
331
|
+
}
|
|
332
|
+
parts.push("realm=" + _qstr(realm), "nonce=" + _qstr(nonce), "uri=" + _qstr(params.uri), "algorithm=" + challenge.algorithm);
|
|
333
|
+
if (useQop) { parts.push("qop=" + useQop); parts.push("nc=" + nc); }
|
|
334
|
+
// The cnonce directive is emitted whenever the server needs it to recompute the response: under a qop, and
|
|
335
|
+
// also under a -sess algorithm whose A1 folds in the cnonce even with no qop (RFC 7616 sec. 3.4.2 / 3.9.1).
|
|
336
|
+
if (useQop || alg.sess) { parts.push("cnonce=" + _qstr(cnonce)); }
|
|
337
|
+
parts.push("response=" + _qstr(response));
|
|
338
|
+
if (challenge.opaque != null) parts.push("opaque=" + _qstr(challenge.opaque));
|
|
339
|
+
if (challenge.userhash) parts.push("userhash=true");
|
|
340
|
+
return "Digest " + parts.join(", ");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Parse one domain URI entry (RFC 7616 sec. 3.3) into { origin, full }: an abs_path ("/a", "/a?x=1") is
|
|
344
|
+
// relative to the responding server -> origin null (any same-origin request); an ABSOLUTE URI carries its own
|
|
345
|
+
// authority -> origin "scheme://authority" (lowercased, default port normalized) and matches ONLY a request to
|
|
346
|
+
// that SAME origin, since an entry to a different host names a protection space on a different server. `full` is
|
|
347
|
+
// the pathname + query the request URI is prefix-compared against. A relative / unparseable absolute entry -> null.
|
|
348
|
+
function _domainEntry(d) {
|
|
349
|
+
d = String(d == null ? "" : d);
|
|
350
|
+
if (d.charAt(0) === "/") return { origin: null, full: d };
|
|
351
|
+
// An absolute entry is parsed through the URL constructor so its origin is NORMALIZED the same way the
|
|
352
|
+
// request's URL.origin is -- an explicit default port (https :443 / http :80) is dropped, so a domain naming
|
|
353
|
+
// "https://h:443/x" still matches a request whose origin normalizes to "https://h". A relative / unparseable
|
|
354
|
+
// entry throws and never matches (fail closed).
|
|
355
|
+
try { var u = new URL(d); return { origin: u.origin.toLowerCase(), full: (u.pathname || "/") + (u.search || "") }; }
|
|
356
|
+
catch (_e) { return null; }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// inProtectionSpace(challenge, requestOrigin, requestPathAndSearch) -> is the request within the challenge's
|
|
360
|
+
// protection space? An absent / empty domain means "all URIs on the responding server" (RFC 7616 sec. 3.3), so
|
|
361
|
+
// any same-origin request matches; otherwise membership is by LITERAL URI PREFIX (RFC 7616 sec. 3.5 / RFC 2617
|
|
362
|
+
// sec. 1.2): the request's pathname + query must have a listed domain URI as a prefix, and for an absolute entry
|
|
363
|
+
// the same origin. The query is part of the prefix, so /enroll?tenant=a does not cover /enroll?tenant=b while a
|
|
364
|
+
// query-less /enroll covers its whole subtree (including /enroll?...). Used to scope where a cached answer is reused.
|
|
365
|
+
function inProtectionSpace(challenge, requestOrigin, requestPathAndSearch) {
|
|
366
|
+
var domain = challenge && challenge.domain;
|
|
367
|
+
if (!domain || !domain.length) return true;
|
|
368
|
+
var origin = String(requestOrigin == null ? "" : requestOrigin).toLowerCase();
|
|
369
|
+
var full = String(requestPathAndSearch == null ? "" : requestPathAndSearch);
|
|
370
|
+
for (var i = 0; i < domain.length; i++) {
|
|
371
|
+
var e = _domainEntry(domain[i]);
|
|
372
|
+
if (e === null) continue;
|
|
373
|
+
if (e.origin !== null && e.origin !== origin) continue; // an absolute entry to a DIFFERENT origin is a different protection space
|
|
374
|
+
if (full.indexOf(e.full) === 0) return true;
|
|
375
|
+
}
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = { parseChallenge: parseChallenge, answer: answer, inProtectionSpace: inProtectionSpace };
|
package/lib/http-transport.js
CHANGED
|
@@ -353,9 +353,11 @@ function httpsTransport(defaults) {
|
|
|
353
353
|
timer = setTimeout(function () { fail(C("timeout"), "the request timed out after " + prep.timeout + "ms"); }, prep.timeout);
|
|
354
354
|
try {
|
|
355
355
|
req = nodeHttps.request(prep.options, function (res) {
|
|
356
|
-
// Capture the TLS session facts while the socket is live -- getProtocol() /
|
|
357
|
-
// getPeerCertificate() return null once the socket detaches at stream end.
|
|
356
|
+
// Capture the TLS session facts while the socket is live -- getProtocol() / getCipher() /
|
|
357
|
+
// getPeerCertificate() return null once the socket detaches at stream end. The negotiated cipher lets a
|
|
358
|
+
// caller refuse a NULL / anonymous / EXPORT suite before it trusts confidentiality (RFC 7030 sec. 4.4).
|
|
358
359
|
var proto = res.socket && res.socket.getProtocol ? res.socket.getProtocol() : null;
|
|
360
|
+
var cipher = res.socket && res.socket.getCipher ? res.socket.getCipher() : null;
|
|
359
361
|
var peer = res.socket && res.socket.getPeerCertificate ? res.socket.getPeerCertificate() : null;
|
|
360
362
|
// Pre-check a declared content-length so an oversized body is refused before it streams.
|
|
361
363
|
var declared = parseInt((res.headers || {})["content-length"], 10);
|
|
@@ -389,7 +391,7 @@ function httpsTransport(defaults) {
|
|
|
389
391
|
status: res.statusCode,
|
|
390
392
|
headers: lower,
|
|
391
393
|
body: Buffer.from(buf.subarray(0, len)),
|
|
392
|
-
tls: { protocol: proto, peerCertificate: peer && peer.raw ? peer.raw : null },
|
|
394
|
+
tls: { protocol: proto, cipher: cipher, peerCertificate: peer && peer.raw ? peer.raw : null },
|
|
393
395
|
});
|
|
394
396
|
});
|
|
395
397
|
res.on("error", function (e) { fail(C("transport-error"), "the response stream failed", e); });
|
package/lib/path-validate.js
CHANGED
|
@@ -41,6 +41,7 @@ var ocsp = require("./schema-ocsp");
|
|
|
41
41
|
var ocspVerify = require("./ocsp-verify");
|
|
42
42
|
var crlVerify = require("./crl-verify");
|
|
43
43
|
var cmpVerify = require("./cmp-verify");
|
|
44
|
+
var cmpSession = require("./cmp-session");
|
|
44
45
|
var guard = require("./guard-all");
|
|
45
46
|
var constants = require("./constants");
|
|
46
47
|
var validator = require("./validator-all");
|
|
@@ -1887,6 +1888,8 @@ var ocspCore = ocspVerify.makeOcspVerify({
|
|
|
1887
1888
|
// out-of-path signer certificate through the FULL RFC 5280 sec. 6.1 path validation -- without exposing any
|
|
1888
1889
|
// of it on the public pki.path surface (index.js exports the whole path-validate module).
|
|
1889
1890
|
cmpVerify.setEngine({ verifyWithSpki: _verifyWithSpki, build: build, validate: validate });
|
|
1891
|
+
// pki.cmp.session validates the ISSUED leaf certificate (its signature + chain) through the same engine.
|
|
1892
|
+
cmpSession.setEngine({ build: build, validate: validate, toAnchor: toAnchor, coerceCert: coerceCert });
|
|
1890
1893
|
|
|
1891
1894
|
/**
|
|
1892
1895
|
* @primitive pki.path.ocspChecker
|
package/lib/sleep.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- the shared bounded poll sleeper for the stateful network clients (pki.acme.client and
|
|
6
|
+
// pki.cmp.session). A delay above Node's 32-bit setTimeout ceiling is SPLIT into chained maximum-size
|
|
7
|
+
// chunks: a bare setTimeout(fn, > 2^31-1) is silently clamped to 1 ms (a TimeoutOverflowWarning) and would
|
|
8
|
+
// then rapidly re-poll instead of waiting the full interval. Each client's opts.sleep overrides this in
|
|
9
|
+
// tests, so this default -- the ONLY sleeper that touches a real timer -- is never driven by a test wait.
|
|
10
|
+
|
|
11
|
+
var SETTIMEOUT_MAX_MS = 2147483647; // 2^31 - 1: Node's setTimeout delay ceiling
|
|
12
|
+
|
|
13
|
+
// sleep(ms) -> Promise resolved after `ms` milliseconds, chunking a delay past the timer ceiling.
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return new Promise(function (resolve) {
|
|
16
|
+
(function step(remaining) {
|
|
17
|
+
if (remaining <= SETTIMEOUT_MAX_MS) { setTimeout(resolve, remaining); return; }
|
|
18
|
+
setTimeout(function () { step(remaining - SETTIMEOUT_MAX_MS); }, SETTIMEOUT_MAX_MS);
|
|
19
|
+
})(ms);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { sleep: sleep, SETTIMEOUT_MAX_MS: SETTIMEOUT_MAX_MS };
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:ea40d20f-ad92-403e-8de1-cfd14bead6ba",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-
|
|
8
|
+
"timestamp": "2026-08-01T18:47:21.356Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.3.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.3.28",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
25
|
+
"version": "0.3.28",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.3.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.3.28",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.3.
|
|
57
|
+
"ref": "@blamejs/pki@0.3.28",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|