@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.
@@ -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 };
@@ -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); });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:2d9fa8bb-e93e-450a-991c-ef2f3b3fb9e2",
5
+ "serialNumber": "urn:uuid:ea40d20f-ad92-403e-8de1-cfd14bead6ba",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-30T20:38:00.777Z",
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.26",
22
+ "bom-ref": "@blamejs/pki@0.3.28",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.26",
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.26",
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.26",
57
+ "ref": "@blamejs/pki@0.3.28",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]