@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,1045 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- the pki.cmp.session implementation. The operator-facing @module pki.cmp home is
6
+ // lib/cmp-build.js; this file adds the @primitive pki.cmp.session block and re-exports the cmp message
7
+ // surface (build / transfer / wellKnownUrl / verify) so the whole pki.cmp namespace wires through here
8
+ // (index.js requires this file as `cmp`).
9
+ //
10
+ // pki.cmp.session -- the STATEFUL CMP enrollment-transaction orchestrator (the pki.acme.client analogue).
11
+ // It composes the shipped message layer (build / transfer / verify) into a single enroll(request): mint a
12
+ // stable transactionID, build + protect + transfer a request, VERIFY every response's protection BEFORE
13
+ // reading its body, chain the nonces (recipNonce echoes the peer's senderNonce, a fresh senderNonce per
14
+ // request) and the transactionID (RFC 9810 sec. 5.1.1 anti-replay / anti-interleave), interpret the
15
+ // CertResponse PKIStatus (grant -> extract the cert, waiting -> a bounded pollReq/pollRep loop, rejection
16
+ // -> a terminal verdict), and confirm (certConf -> pkiConf, unless implicitConfirm was granted). The
17
+ // single invariant is transfer -> verify (fail-closed) -> ONLY THEN read the body off the verdict.
18
+
19
+ var cmp = require("./cmp-verify"); // re-exports build / transfer / wellKnownUrl / verify (the whole message layer)
20
+ var asn1 = require("./asn1-der");
21
+ var oid = require("./oid");
22
+ var x509 = require("./schema-x509");
23
+ var schemaCmp = require("./schema-cmp");
24
+ var csr = require("./schema-csr");
25
+ var guard = require("./guard-all");
26
+ var compositeSig = require("./composite-sig");
27
+ var constants = require("./constants");
28
+ var webcrypto = require("./webcrypto");
29
+ var frameworkError = require("./framework-error");
30
+ var sleepUtil = require("./sleep");
31
+ var retryAfter = require("./http-retry-after");
32
+
33
+ var CmpError = frameworkError.CmpError;
34
+ function _err(code, message, cause) { return new CmpError(code, message, cause); }
35
+ var OID_IMPLICIT_CONFIRM = oid.byName("implicitConfirm"); // classify a granted implicitConfirm by its IMMUTABLE OID
36
+
37
+ // The certification-path build/validate engine, injected by path-validate (the crl/ocsp/cmp-verify seam) so
38
+ // the session can VALIDATE the issued leaf certificate's signature + chain -- x509.parse is structural only.
39
+ var _engine = null;
40
+ function setEngine(engine) { _engine = engine; }
41
+
42
+ // The signature algorithms whose OID conveys NO message hash -- EdDSA (the hash is the scheme) and the FIPS
43
+ // PQC signatures (ML-DSA / SLH-DSA, internally hashed). ONLY these may take the certConf SHA-256 + explicit
44
+ // hashAlg fallback (RFC 9810 sec. 5.3.18); any other indeterminate/non-signature AlgorithmIdentifier (e.g.
45
+ // rsaEncryption, an ML-KEM OID) is refused. Resolved to a dotted-OID set at load (never a runtime lookup).
46
+ var HASHLESS_SIG_OIDS = {};
47
+ (function () {
48
+ var names = ["Ed25519", "Ed448", "id-ml-dsa-44", "id-ml-dsa-65", "id-ml-dsa-87"];
49
+ ["sha2", "shake"].forEach(function (h) { ["128s", "128f", "192s", "192f", "256s", "256f"].forEach(function (s) { names.push("id-slh-dsa-" + h + "-" + s); }); });
50
+ names.forEach(function (n) { var o = oid.byName(n); if (o) HASHLESS_SIG_OIDS[o] = 1; });
51
+ })();
52
+ // Hash-conveying signature algorithms -> the certConf hash, dispatched by the IMMUTABLE OID (never the mutable
53
+ // display name, which pki.oid.register can override). Resolved to a dotted-OID map at load.
54
+ var SIG_OID_TO_HASH = {};
55
+ [["sha256WithRSAEncryption", "SHA-256"], ["sha384WithRSAEncryption", "SHA-384"], ["sha512WithRSAEncryption", "SHA-512"],
56
+ ["ecdsaWithSHA256", "SHA-256"], ["ecdsaWithSHA384", "SHA-384"], ["ecdsaWithSHA512", "SHA-512"]].forEach(function (row) {
57
+ var o = oid.byName(row[0]); if (o) SIG_OID_TO_HASH[o] = row[1];
58
+ });
59
+ var OID_RSASSA_PSS = oid.byName("rsassaPss");
60
+ var OID_RSA_ENCRYPTION = oid.byName("rsaEncryption");
61
+ // Message-digest OID -> the WebCrypto digest name, for the RSASSA-PSS hashAlgorithm parameter -- dispatched by
62
+ // the IMMUTABLE OID (never oid.name, which pki.oid.register can rename). Resolved to a dotted-OID map at load.
63
+ var HASH_OID_TO_DIGEST = {};
64
+ [["sha256", "SHA-256"], ["sha384", "SHA-384"], ["sha512", "SHA-512"]].forEach(function (row) { var o = oid.byName(row[0]); if (o) HASH_OID_TO_DIGEST[o] = row[1]; });
65
+ // A composite signature's declared prehash (COMPOSITE_ALGS[oid].ph) -> the certConf hashAlg field value.
66
+ var COMPOSITE_PH_HASHALG = { "SHA-256": "sha256", "SHA-384": "sha384", "SHA-512": "sha512" };
67
+
68
+ var KNOWN_SESSION_OPTS = {
69
+ url: 1, key: 1, cert: 1, mac: 1, trustAnchors: 1, intermediates: 1, recipient: 1, sender: 1,
70
+ extraCerts: 1, implicitConfirm: 1, maxPolls: 1, maxTotalWait: 1, sleep: 1, time: 1,
71
+ transport: 1, tls: 1, headers: 1, timeout: 1, maxResponseBytes: 1, pss: 1, digestAlgorithm: 1,
72
+ acceptCert: 1, senderKID: 1, recipKID: 1, expectedSender: 1,
73
+ };
74
+
75
+ // The read-only transcript retains at most this many responses' worth of payload bytes (a response is bounded by
76
+ // the transport's maxResponseBytes). Bounding it PROPORTIONALLY to the per-response cap keeps the ceiling above
77
+ // any single legitimate response while still preventing a polling loop from multiplying a padded-response flood
78
+ // across legs into memory exhaustion; a tightened maxResponseBytes tightens the transcript ceiling in step.
79
+ var TRANSCRIPT_RETAIN_RESPONSES = 2;
80
+ var DEFAULT_MAX_POLLS = 20;
81
+ var DEFAULT_MAX_TOTAL_WAIT = retryAfter.MAX_RETRY_AFTER_SECONDS; // the shared retry-after ceiling (seconds)
82
+ var DEFAULT_CERT_REQ_ID = 0; // the CRMF single-request certReqId when the caller supplies none (ir / cr / kur)
83
+ var P10CR_CERT_REQ_ID = -1; // a PKCS#10 request has no CRMF id; a conforming cp identifies it with the -1 sentinel (RFC 9483)
84
+ var ENROLL_ARMS = { ir: 1, cr: 1, kur: 1, p10cr: 1 }; // the initial request arms enroll() accepts
85
+ // The response body arm each enrollment request is answered by (RFC 9810 sec. 5.3.2/5.3.4): ir->ip, cr/p10cr
86
+ // ->cp, kur->kup. A response of a DIFFERENT cert-response arm than the request is misrouted -- never confirmed.
87
+ var RESPONSE_ARM = { ir: "ip", cr: "cp", kur: "kup", p10cr: "cp" };
88
+
89
+ // PKIStatus codes (RFC 9810 sec. 5.2.3): 0 accepted, 1 grantedWithMods, 2 rejection, 3 waiting.
90
+ function _isGranted(code) { return code === 0 || code === 1; }
91
+ // The PKIStatus name surfaced to an acceptance policy so it can distinguish a clean grant from a modified one.
92
+ var PKI_STATUS_NAMES = { 0: "accepted", 1: "grantedWithMods", 2: "rejection", 3: "waiting" };
93
+
94
+ // certReqId equality across the encode side (a number or bigint in the request) and the decode side (a BigInt
95
+ // off the wire): compare as BigInts so two distinct large ids above 2^53 never collide under Number rounding.
96
+ function _certReqIdEq(a, b) { return a != null && b != null && BigInt(a) === BigInt(b); }
97
+
98
+ // Normalize a certificate-list option (the constructor + cmp.verify accept a lone Buffer / PEM OR an array) to
99
+ // an array the path engine requires; a copy so a caller's array is never mutated by the caPubs append.
100
+ function _asCertList(v) { return v == null ? [] : (Array.isArray(v) ? v.slice() : [v]); }
101
+
102
+ // Append session-supplied pool material (a CA-delivered caPubs, a cached protection-signer chain part-derived
103
+ // from the response's UNSIGNED extraCerts) to a `base` pool (the caller's OWN intermediates) up to the path
104
+ // builder's candidate ceiling, deduped against each other AND the base's Buffer entries so a copy of an existing
105
+ // candidate never spends a slot. The base is never truncated (if it alone exceeds the ceiling, that is a genuine
106
+ // config error path.build reports). So neither a legitimate caPubs nor a meddler's extraCerts flood can push a
107
+ // valid caller pool over the ceiling and fail an otherwise-valid grant. Used for BOTH the response signer path
108
+ // (cmp.verify) and the issued-leaf path (path.build), the two places session material joins a caller pool.
109
+ function _boundedPool(base, added) {
110
+ // Dedup the BASE (the priority pool) FIRST: duplicate copies would otherwise inflate its length, drive the
111
+ // remaining room to zero, and evict genuinely needed added material even though the DISTINCT candidate count is
112
+ // small (path.build dedups internally, so dropping exact duplicates is behavior-preserving). An uncanonicalizable
113
+ // entry is kept as-is (path.build judges it) and cannot dedup an added cert. The base holds the priority material
114
+ // (a signed response's OWN delivered issuers + the cached chain, or the grant's caPubs) so it is never truncated;
115
+ // the added caller pool fills the remaining room to the candidate ceiling.
116
+ var ceiling = constants.LIMITS.PATH_BUILD_MAX_CANDIDATES;
117
+ var seen = Object.create(null), distinctBase = [];
118
+ base.forEach(function (c) { var k = _certIdentity(c); if (k == null) { distinctBase.push(c); return; } if (!seen[k]) { seen[k] = 1; distinctBase.push(c); } });
119
+ var room = ceiling - distinctBase.length;
120
+ if (room <= 0) return distinctBase;
121
+ var useful = [];
122
+ added.forEach(function (c) { var k = _certIdentity(c); if (k != null && seen[k]) return; if (k != null) seen[k] = 1; useful.push(c); });
123
+ return distinctBase.concat(useful.slice(0, room));
124
+ }
125
+ // A canonical byte identity for a certificate in ANY form path.build accepts -- a DER Buffer, a PEM string, or
126
+ // an already-parsed certificate object -- so a caller intermediate supplied as PEM or parsed still dedups against
127
+ // a byte-identical DER caPubs / cached certificate and does not spend a scarce candidate slot on a copy. The
128
+ // identity is the tbsCertificate bytes PLUS the signature (mirroring cmp.verify's _certKey): a meddler controls
129
+ // the unsigned extraCerts ordering, so a corrupted-signature copy sharing a valid issuer's TBS must NOT collapse
130
+ // onto it and evict the valid one. Returns null when the identity cannot be derived; a NON-deduped entry is safe
131
+ // (a redundant slot), a wrong merge (dropping a distinct or the only valid certificate) is not.
132
+ function _certIdentity(cert) {
133
+ try {
134
+ var p = (cert && Buffer.isBuffer(cert.tbsBytes)) ? cert : x509.parse(cert); // x509.parse accepts a DER Buffer OR a PEM string
135
+ if (!p || !Buffer.isBuffer(p.tbsBytes)) return null;
136
+ return p.tbsBytes.toString("base64") + "|" + (p.signatureValue && p.signatureValue.bytes ? p.signatureValue.bytes.toString("base64") : "");
137
+ } catch (_e) { return null; }
138
+ }
139
+
140
+ // A canonical identity for a SubjectPublicKeyInfo: the algorithm OID + the AlgorithmIdentifier parameters +
141
+ // the raw subjectPublicKey BIT STRING. The parameters ARE part of the key identity (an EC curve OID, an
142
+ // RSASSA-PSS constraint set) and are kept -- EXCEPT for rsaEncryption, whose parameters are NULL or omitted,
143
+ // both naming the same key. So the issued-cert key-match compares KEYS (with their constraints), not byte
144
+ // encodings, and neither rejects an equivalent rsaEncryption re-encoding nor accepts a constraint-changed key.
145
+ function _spkiKeyIdentity(spkiDer) {
146
+ var node = asn1.decode(spkiDer); // SEQUENCE { AlgorithmIdentifier { OID, params? }, BIT STRING }
147
+ var algId = node.children[0];
148
+ var algOid = asn1.read.oid(algId.children[0]);
149
+ var pn = algId.children[1];
150
+ var params;
151
+ if (algOid === OID_RSA_ENCRYPTION) {
152
+ // rsaEncryption parameters MUST be absent or a NULL (RFC 3279 sec. 2.3.1). Normalize ONLY those two
153
+ // equivalent forms to "" so a NULL-vs-omitted re-encoding matches. ANY other value (a malformed empty OCTET
154
+ // STRING the parser/importer may tolerate, or a changed parameter) keeps its bytes -- so a parameter-changed
155
+ // certificate a stricter consumer rejects gets a DISTINCT identity and the key-match refuses it.
156
+ params = (pn == null || _isDerNull(pn)) ? "" : pn.bytes.toString("latin1");
157
+ } else {
158
+ // Every other algorithm's parameters ARE part of the key identity (an EC curve OID, an RSASSA-PSS constraint set).
159
+ params = pn ? pn.bytes.toString("latin1") : "";
160
+ }
161
+ return algOid + "|" + params + "|" + node.children[1].bytes.toString("latin1");
162
+ }
163
+ // A well-formed DER NULL node (universal tag 5, empty content) -- the only rsaEncryption parameter form treated
164
+ // as equivalent to an absent parameter.
165
+ function _isDerNull(pn) { return pn.tagClass === "universal" && pn.tagNumber === 5 && pn.content.length === 0; }
166
+
167
+ // The BOUNDED, distinct, parseable extraCerts of an already-verified response, cached so a later leg that
168
+ // omits extraCerts can rebuild the signer path -- MIRRORING cmp.verify's own extraCerts bounding: dedup, drop
169
+ // any non-X.509 entry, cap at MAX_EXTRA_CERTS, and stop after MAX_EXTRA_SCAN entries. So a meddler appending a
170
+ // flood of unsigned certs cannot make an otherwise-valid enrollment fail when the cache reaches path.build.
171
+ var MAX_EXTRA_CERTS = 32, MAX_EXTRA_SCAN = 256;
172
+ // The caller intermediates pool is capped BELOW the path-builder candidate ceiling, reserving room for the CA's
173
+ // OWN authenticated material (a response's extraCerts + the cached signer chain, or the grant's caPubs + cache --
174
+ // each <= MAX_EXTRA_CERTS). So the authenticated certs AND the whole caller pool ALWAYS fit in ONE candidate pool:
175
+ // no priority attempt has to choose between them, and a chain assembled from BOTH sources validates in one build.
176
+ var CAPUBS_MAX = 2 * MAX_EXTRA_CERTS; // the leaf's authenticated issuer material (caPubs) -- bounded so caPubs + cached chain + the caller pool fit under the ceiling
177
+ // The caller intermediates pool is capped below the path-builder ceiling by the room the session's OWN material
178
+ // can occupy. A SIGNATURE session reserves CAPUBS_MAX (a grant's caPubs, leaf validation) PLUS MAX_EXTRA_CERTS (the
179
+ // response's extraCerts + cached SIGNER chain -- the signer-path pool). A MAC session authenticates the response by
180
+ // the shared secret and NEVER adds a signer chain (its cache gate is isSig), so it reserves ONLY CAPUBS_MAX -- its
181
+ // sole pool is _validateLeaf's caPubs + caller. So every candidate pool the session builds holds the whole caller
182
+ // pool AND all authenticated material at once -- one build, no priority choice, no retry.
183
+ var SESSION_MAX_INTERMEDIATES = constants.LIMITS.PATH_BUILD_MAX_CANDIDATES - CAPUBS_MAX - MAX_EXTRA_CERTS; // signature
184
+ var SESSION_MAX_INTERMEDIATES_MAC = constants.LIMITS.PATH_BUILD_MAX_CANDIDATES - CAPUBS_MAX; // MAC (no signer chain reserved)
185
+ // A verify verdict whose failure a DIFFERENT candidate pool or the cached/prebound signer might still recover:
186
+ // extraCerts is outside the protected part, so a signer that did not resolve, a wrong-key/wrong-subject decoy
187
+ // selected first, or an untrusted chain can all be retried. A transaction-integrity failure (transactionID /
188
+ // recipNonce mismatch) is NOT here -- it is decoy-independent and re-running would only mask the real desync.
189
+ function _isRecoverableVerify(code) {
190
+ return code === "cmp/signer-cert-not-found" || code === "cmp/protection-failed" ||
191
+ code === "cmp/sender-mismatch" || code === "cmp/untrusted-signer";
192
+ }
193
+ function _responseExtraCerts(responseBytes) {
194
+ var extra = schemaCmp.parse(responseBytes).extraCerts;
195
+ if (!Array.isArray(extra)) return [];
196
+ var out = [], seen = Object.create(null);
197
+ for (var i = 0; i < extra.length && out.length < MAX_EXTRA_CERTS && i < MAX_EXTRA_SCAN; i++) {
198
+ var c = extra[i];
199
+ if (!Buffer.isBuffer(c)) continue;
200
+ var key = c.toString("base64");
201
+ if (seen[key]) continue;
202
+ seen[key] = true;
203
+ try { x509.parse(c); }
204
+ catch (_e) { continue; } // drop a malformed entry, mirroring cmp.verify's bounding
205
+ out.push(Buffer.from(c)); // COPY out of the parser's subarray -- retaining the slice would pin the whole multi-MB response buffer if this entry is cached (the caPubs accumulator copies for the same reason)
206
+ }
207
+ return out;
208
+ }
209
+
210
+ // Normalize a caller-supplied CRMF certReqId to the value the session echoes + matches, the SAME way
211
+ // crmf-sign._certReqId encodes it: a number or bigint is kept; a STRING (decimal or 0x-hex) is parsed via
212
+ // BigInt so a supported form ("5" / "0x5") is not silently replaced by the default; anything else -> `dflt`.
213
+ function _normalizeCertReqId(cid, dflt) {
214
+ if (typeof cid === "bigint" || typeof cid === "number") return cid;
215
+ if (typeof cid === "string") {
216
+ try { return BigInt(cid); }
217
+ catch (_e) { /* allow:swallow-unverified an invalid certReqId string fails closed at the cmp.build boundary; this best-effort normalize just does not pre-empt that typed error */ return dflt; }
218
+ }
219
+ return dflt;
220
+ }
221
+
222
+ // The hash inside RSASSA-PSS-params (the hashAlgorithm [0] AlgorithmIdentifier OID) -- for id-RSASSA-PSS the
223
+ // digest lives in the parameters, not the OID name (RFC 4055). Returns "SHA-256" / "SHA-384" / "SHA-512", or
224
+ // null when the params are absent / unreadable / name an unmapped hash (the caller then declares SHA-256).
225
+ function _pssDigest(paramsBytes) {
226
+ if (!Buffer.isBuffer(paramsBytes) || paramsBytes.length === 0) return null;
227
+ try {
228
+ var node = asn1.decode(paramsBytes);
229
+ if (!node.children) return null;
230
+ for (var i = 0; i < node.children.length; i++) {
231
+ var f = node.children[i];
232
+ if (f.tagClass === "context" && f.tagNumber === 0 && f.children && f.children.length === 1) {
233
+ var algSeq = f.children[0];
234
+ if (!algSeq.children || algSeq.children.length < 1) return null;
235
+ return HASH_OID_TO_DIGEST[asn1.read.oid(algSeq.children[0])] || null; // by immutable OID, not oid.name
236
+ }
237
+ }
238
+ } catch (_e) { /* allow:swallow-unverified a malformed PSS-params blob falls back to null -> the caller's declared-SHA-256 path; a display-hash inference never throws */ return null; }
239
+ return null;
240
+ }
241
+
242
+ // The certConf certHash algorithm (RFC 9810 sec. 5.3.18): use the SAME hash the certificate signature uses.
243
+ // If the signatureAlgorithm OID conveys the hash (sha256WithRSAEncryption / ecdsaWithSHA384 / ...) use THAT
244
+ // hash and OMIT hashAlg. For id-RSASSA-PSS the hash is carried in the params, so decode it and likewise omit
245
+ // hashAlg. Only when the hash is genuinely not conveyed (Ed25519 / Ed448; ML-DSA / SLH-DSA) is SHA-256 used
246
+ // and DECLARED in the explicit hashAlg field so the CA recomputes certHash under the same stated hash.
247
+ function _certConfHash(certDer) {
248
+ var sa;
249
+ try { sa = x509.parse(certDer).signatureAlgorithm; }
250
+ catch (e) { throw _err("cmp/bad-cert-response", "the issued certificate is unexpectedly unparseable at certConf", e); } // unreachable: _leafOf already validated it; re-throw fail-closed
251
+ // Dispatch by the IMMUTABLE signatureAlgorithm OID (never the mutable display name). A hashless SIGNATURE
252
+ // (EdDSA / ML-DSA / SLH-DSA) -> SHA-256 + an explicit hashAlg; a hash-conveying signature -> its hash, no
253
+ // hashAlg; id-RSASSA-PSS -> the hash from its parameters, no hashAlg.
254
+ if (HASHLESS_SIG_OIDS[sa.oid]) return { digest: "SHA-256", hashAlg: "sha256" };
255
+ // A composite signature (draft-ietf-lamps-pq-composite-sigs): compute certHash under the composite's own
256
+ // declared PREHASH digest (COMPOSITE_ALGS[oid].ph) and DECLARE it in the explicit hashAlg. If the prehash is
257
+ // not a certConf-representable hash (SHAKE256, which the CMP CertStatus hashAlg cannot name), FAIL closed --
258
+ // substituting SHA-256 would send a certHash under a hash that contradicts the signature's declared prehash,
259
+ // which a conforming CA rejects. Refuse rather than confirm under a false algorithm (RFC 9810 sec. 5.3.18).
260
+ var comp = compositeSig.COMPOSITE_ALGS[sa.oid];
261
+ if (comp) {
262
+ var ha = COMPOSITE_PH_HASHALG[comp.ph];
263
+ if (ha) return { digest: comp.ph, hashAlg: ha };
264
+ throw _err("cmp/bad-cert-response", "the issued certificate's composite signature prehash (" + comp.ph + ") is not a certConf-representable hash; the certConf certHash cannot be declared truthfully (RFC 9810 sec. 5.3.18)");
265
+ }
266
+ if (SIG_OID_TO_HASH[sa.oid]) return { digest: SIG_OID_TO_HASH[sa.oid], hashAlg: null };
267
+ if (sa.oid === OID_RSASSA_PSS) {
268
+ var pd = _pssDigest(sa.parameters);
269
+ if (pd) return { digest: pd, hashAlg: null };
270
+ throw _err("cmp/bad-cert-response", "the issued RSASSA-PSS certificate's hash cannot be resolved from its parameters (RFC 4055); the certConf hash is indeterminate");
271
+ }
272
+ // Any other AlgorithmIdentifier -- an unregistered OID, or a registered NON-signature / indeterminate one
273
+ // (rsaEncryption, an ML-KEM OID, ...): the required certConf hash cannot be determined. Do NOT guess
274
+ // SHA-256 (a wrong certHash the CA rejects); fail the transaction closed before confirming.
275
+ throw _err("cmp/bad-cert-response", "the issued certificate's signature algorithm does not determine a certConf hash (an unrecognized or non-signature algorithm); the transaction is refused (RFC 9810 sec. 5.3.18)");
276
+ }
277
+
278
+ /**
279
+ * @primitive pki.cmp.session
280
+ * @signature pki.cmp.session(opts) -> session
281
+ * @since 0.3.27
282
+ * @status experimental
283
+ * @spec RFC 9810, RFC 9811, RFC 9483
284
+ * @related pki.cmp.build, pki.cmp.verify, pki.cmp.transfer
285
+ *
286
+ * A stateful RFC 9810 CMP enrollment-transaction orchestrator -- the `pki.acme.client` analogue. It drives
287
+ * an enrollment (`ir` / `cr` / `kur` / `p10cr`) end to end over the shared `pki.transport` (inject
288
+ * `opts.transport`, else a fail-closed `pki.transport.https`), composing the shipped message layer
289
+ * (`build` / `transfer` / `verify`). It mints a stable 128-bit `transactionID`, and on every request a
290
+ * FRESH `senderNonce`, echoing the peer's last `senderNonce` back as `recipNonce` (RFC 9810 sec. 5.1.1
291
+ * anti-replay / anti-interleave). The load-bearing invariant: every response is protection-VERIFIED and
292
+ * nonce-bound to this exchange BEFORE its body is read -- so a meddler who flips an HTTP response cannot
293
+ * forge a granted status or a poison `checkAfter`. A `waiting` status drives a bounded `pollReq`/`pollRep`
294
+ * loop (an injectable sleeper, capped by `maxPolls` + `maxTotalWait`); a grant extracts the issued cert and
295
+ * confirms it (`certConf` -> `pkiConf`, unless an `implicitConfirm` was granted). A verified `rejection` /
296
+ * `error` or a poll-budget timeout is a terminal typed VERDICT the caller reads (`outcome`:
297
+ * `issued` / `rejected` / `poll-timeout`); a tampered / unverifiable / desynchronized response is a
298
+ * hard-stop `CmpError` throw. Exactly ONE protection flavor: `{ key, cert }` (signature) XOR `{ mac }`
299
+ * (PBMAC1). A crypto-valid response is not enough -- the signer must chain to a supplied trust anchor
300
+ * (signature) or the shared secret must match (MAC); a valid-but-untrusted response is a hard stop, so the
301
+ * signature flavor REQUIRES `opts.trustAnchors` at construction. Returns a session with `enroll(request)`,
302
+ * and read-only `transactionID` + `transcript` (each leg's request/response bytes, retained up to a
303
+ * transaction-wide cap; a later leg beyond the cap keeps its metadata + `byteLength` but drops the payload as
304
+ * `bytes: null, truncated: true`, so a padded-response flood across polls cannot exhaust memory). The granted certificate must be valid X.509, carry the key
305
+ * the request submitted (else it is a misrouted certificate the caller cannot use), and -- for the signature
306
+ * flavor -- have its signature + chain validated to a supplied trust anchor before it is confirmed. The `certConf`
307
+ * `certHash` uses the certificate's signature hash -- from the OID when it conveys one, or from the
308
+ * RSASSA-PSS parameters when it does not, with `hashAlg` omitted; only a truly hashless signature
309
+ * (Ed25519 / Ed448, ML-DSA / SLH-DSA) computes under SHA-256 and declares an explicit `hashAlg`
310
+ * (RFC 9810 sec. 5.3.18). The `certReqId` echoed in `pollReq` / `certConf` and matched in every
311
+ * `CertResponse` is the caller's CRMF request id (`request.ir.certReqId`, ...) when supplied, else the
312
+ * single-request default. One transaction per session -- a second or concurrent `enroll`, or a batched
313
+ * CRMF request, is refused (a local build error leaves the session retryable). The returned `chain` is the
314
+ * issued leaf plus any authenticated `caPubs` the CA delivered (chain material, never trust anchors); a
315
+ * server-generated (central key generation) private key is out of scope and the grant is refused.
316
+ *
317
+ * @opts
318
+ * - `url` -- REQUIRED: the CMP endpoint URL.
319
+ * - `key` + `cert` -- signature protection (the enrolling key pair + its cert), XOR `mac: { secret, ... }` -- PBMAC1 protection.
320
+ * - `trustAnchors` -- REQUIRED for the signature flavor (chains + authenticates the CA's response signer cert); OPTIONAL for a MAC session, where it validates the ISSUED certificate's own signature + chain before confirmation (not the response protection). `intermediates` -- extra chain pool.
321
+ * - `sender` / `recipient` -- header GeneralNames; default the signer cert's subject DN (sender) and a NULL-DN (recipient). A signature-protection certificate with an EMPTY subject (identified only by its subjectAltName) REQUIRES an explicit `sender` -- the empty subject cannot name the requester for a peer that binds the sender to the SAN.
322
+ * - `senderKID` / `recipKID` -- optional key identifiers emitted on every request header, so a CA selecting among several shared secrets (senderKID) or recipient keys resolves the right credential.
323
+ * - `expectedSender` -- optional CA signer CERTIFICATE (DER Buffer / PEM string / already-parsed `pki.schema.x509.parse` object); when set, every signed response's authenticated header sender MUST bind to it under the RFC 5280 sec. 7 subject-or-subjectAltName rule cmp.verify uses, so a re-encoded-but-equivalent DN and an empty-subject CA named only by a directoryName SAN both match. Given as bytes/PEM it ALSO resolves a first response that omits its own extraCerts (a CA that assumes the client already holds its certificate). Absent, the session pins the first signed response's signer certificate and requires every later leg's sender to bind to it -- rejecting a switch to a different trusted signer while permitting same-identity certificate/key rotation.
324
+ * - `implicitConfirm` -- request implicit confirmation (skip the certConf leg when the CA grants it).
325
+ * - `acceptCert` -- an async policy `(certDer, { status, grantedWithMods }) => boolean` consulted before the certConf; return true to accept, anything else to veto (a `grantedWithMods` certificate the CA changed). A veto sends a REJECTING certConf and yields `outcome: "rejected"` with the certificate still surfaced. Incompatible with `implicitConfirm` (no reject leg exists) -- the combination throws at construction.
326
+ * - `transport` -- injectable transport(request) -> {status, headers, body}; default pki.transport.https.
327
+ * - `tls` / `headers` / `timeout` / `maxResponseBytes` -- transport config + budgets.
328
+ * - `maxPolls` / `maxTotalWait` / `sleep` -- poll-loop budgets + an injectable sleeper; `time` -- verify-time clock.
329
+ * - `extraCerts` / `pss` / `digestAlgorithm` -- passed through to the request protection build.
330
+ * @example
331
+ * var session = pki.cmp.session({ url: "https://ca.example/cmp", key: signerKeyPkcs8, cert: signerCertDer, trustAnchors: [cmpCaCert], transport: cmpTransport });
332
+ * var result = await session.enroll({ ir: { certTemplate: { subject: [{ commonName: "device-42" }], publicKey: signerSpki } } });
333
+ * if (result.outcome === "issued") { var leaf = result.certificate; }
334
+ */
335
+ function session(opts) {
336
+ if (opts == null) opts = {};
337
+ if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("cmp/bad-input", "opts must be an object");
338
+ Object.keys(opts).forEach(function (k) { if (!KNOWN_SESSION_OPTS[k]) throw _err("cmp/bad-input", "unknown session opts field " + JSON.stringify(k)); });
339
+ // Work on a SHALLOW COPY so a later normalization (e.g. an empty MAC trustAnchors list) never mutates the
340
+ // caller's object -- which may be frozen or reused across sessions -- and normalizing a frozen input does not
341
+ // throw a raw TypeError. Only top-level fields are reassigned; nested values are read, never mutated.
342
+ opts = Object.assign({}, opts);
343
+ if (typeof opts.url !== "string" || !opts.url) throw _err("cmp/bad-input", "opts.url (the CMP endpoint) is required");
344
+
345
+ // EXACTLY ONE protection flavor -- signature (key + cert) XOR PBMAC1 (mac), mirroring cmp.build / cmp.verify.
346
+ var isSig = opts.key != null || opts.cert != null;
347
+ var isMac = opts.mac != null;
348
+ if (isSig === isMac) throw _err("cmp/bad-input", "supply EXACTLY ONE protection flavor: { key, cert } (signature) OR { mac } (PBMAC1)");
349
+ if (isSig && (opts.key == null || opts.cert == null)) throw _err("cmp/bad-input", "signature protection requires BOTH opts.key and opts.cert");
350
+ // Signature protection can only be AUTHENTICATED against a trust anchor: without one, a response signer is
351
+ // resolved from the message's own (unsigned) extraCerts and can be any self-signed key an attacker on the
352
+ // transport supplies. Require trustAnchors up front for the signature flavor. A MAC (PBMAC1) session
353
+ // authenticates the RESPONSE by the shared secret, so anchors are OPTIONAL there -- but when supplied they
354
+ // still validate the ISSUED certificate's own signature + chain before it is confirmed (the MAC authenticates
355
+ // the exchange, not the embedded X.509 signature); they are NOT forwarded to the MAC response verify.
356
+ if (isSig) {
357
+ var hasAnchors = opts.trustAnchors != null && !(Array.isArray(opts.trustAnchors) && opts.trustAnchors.length === 0);
358
+ if (!hasAnchors) throw _err("cmp/bad-input", "signature protection requires opts.trustAnchors to authenticate the CA's response signer (RFC 9483 sec. 3.2)");
359
+ }
360
+ // A MAC session's anchors are OPTIONAL (used only to validate the issued certificate's own chain, never the MAC
361
+ // response authentication): an empty list means "no issued-cert chain validation", exactly like omitting it. The
362
+ // signature branch above already rejects [] outright. Normalize the MAC empty list to absent so _validateLeaf /
363
+ // _verifyOpts treat it uniformly -- otherwise the empty trust store reaches _engine.build and rejects the issued
364
+ // certificate only in _finish, AFTER the authenticated grant has consumed the one-shot session. This assigns to
365
+ // the shallow COPY made above, never the caller's (possibly frozen) options object.
366
+ if (isMac && Array.isArray(opts.trustAnchors) && opts.trustAnchors.length === 0) opts.trustAnchors = null;
367
+ // Parse + validate each supplied anchor + intermediate NOW (through the SAME path-builder normalization the
368
+ // verify + issued-cert validation use), so an unusable trust input (a non-certificate string, a malformed
369
+ // tuple, junk bytes) is a construction error rather than a failure discovered only after the one-shot
370
+ // transaction has already engaged the transport.
371
+ if (opts.trustAnchors != null && _engine && _engine.toAnchor) _asCertList(opts.trustAnchors).forEach(function (a) { try { _engine.toAnchor(a); } catch (e) { throw _err("cmp/bad-input", "opts.trustAnchors: each entry must be a certificate (DER/PEM/parsed) or a { name, publicKey, algorithm } anchor tuple -- " + ((e && e.message) || e), e); } });
372
+ if (opts.intermediates != null && _engine && _engine.coerceCert) _asCertList(opts.intermediates).forEach(function (c) { try { _engine.coerceCert(c); } catch (e) { throw _err("cmp/bad-input", "opts.intermediates: each entry must be a certificate (DER/PEM/parsed) -- " + ((e && e.message) || e), e); } });
373
+ // opts.expectedSender pins the CA's SIGNER CERTIFICATE (not a subject string): every signed response's
374
+ // authenticated header sender must bind to it under cmp.verify's own subject-or-subjectAltName rule, so an
375
+ // empty-subject CA named only by a directoryName SAN -- which a subject-string pin reads as an unmatchable null
376
+ // -- is bound correctly. Accept the documented DER Buffer / PEM string / already-parsed certificate at
377
+ // construction; a non-certificate value would otherwise consume the one-shot transaction before the first
378
+ // verify could reject it. The parsed form drives the identity bind; the DER (kept when supplied as bytes/PEM)
379
+ // resolves a first response that omits its extraCerts (below).
380
+ var _expectedSenderCert = null; // parsed, for the senderBoundToCert identity pin
381
+ var _expectedSenderDer = null; // DER, for the omitted-extraCerts signer fallback (null when given the already-parsed form, which retains no source DER)
382
+ if (opts.expectedSender != null) {
383
+ var _es = opts.expectedSender;
384
+ try {
385
+ if (_es && Buffer.isBuffer(_es.tbsBytes)) { // the documented already-parsed form (pki.schema.x509.parse output), detected like _certIdentity
386
+ // A Buffer tbsBytes alone is NOT a complete parsed certificate: senderBoundToCert dereferences subject /
387
+ // subjectAltName, so a partial object (e.g. { tbsBytes }) would pass here and then throw a raw TypeError
388
+ // mid-transaction, consuming the one-shot session for a local config error. Validate the FULL shape now
389
+ // with the same coerceCert check the path engine applies to every parsed-cert input.
390
+ if (_engine && _engine.coerceCert) _engine.coerceCert(_es);
391
+ _expectedSenderCert = _es;
392
+ }
393
+ else if (Buffer.isBuffer(_es) || _es instanceof Uint8Array) { _expectedSenderDer = Buffer.from(_es); _expectedSenderCert = x509.parse(_expectedSenderDer); }
394
+ else if (typeof _es === "string") { _expectedSenderDer = x509.pemDecode(_es); _expectedSenderCert = x509.parse(_expectedSenderDer); }
395
+ else { throw _err("cmp/bad-input", "opts.expectedSender must be a certificate DER Buffer / PEM string / parsed certificate"); }
396
+ } catch (e) {
397
+ if (e.isCmpError) throw e;
398
+ throw _err("cmp/bad-input", "opts.expectedSender must be the CA signer certificate (DER Buffer / PEM / parsed) so every signed response can be bound to it -- " + ((e && e.message) || e), e);
399
+ }
400
+ }
401
+ // Reject a DISTINCT intermediate pool exceeding the per-flavor cap (the candidate ceiling MINUS the room reserved
402
+ // for the CA's own authenticated material). The caller pool is never truncated, so the whole pool + that material
403
+ // must fit under path.build's ceiling; an oversized one would otherwise raise path/bad-input only at the first
404
+ // verify, after the one-shot transaction is consumed. A MAC session reserves LESS (no signer chain), so its cap is
405
+ // higher. (Duplicates collapse, so the count is by distinct id.)
406
+ var maxIntermediates = isSig ? SESSION_MAX_INTERMEDIATES : SESSION_MAX_INTERMEDIATES_MAC;
407
+ if (opts.intermediates != null) {
408
+ var seenInt = Object.create(null), distinctInt = 0;
409
+ _asCertList(opts.intermediates).forEach(function (c) { var k = _certIdentity(c); if (k == null) { distinctInt++; return; } if (!seenInt[k]) { seenInt[k] = 1; distinctInt++; } });
410
+ if (distinctInt > maxIntermediates) throw _err("cmp/bad-input", "opts.intermediates has " + distinctInt + " distinct certificates, exceeding the " + maxIntermediates + " limit (room is reserved below the path-builder ceiling for the CA's own delivered issuer certificates" + (isSig ? " and signer chain" : "") + ")");
411
+ }
412
+
413
+ // A certificate-acceptance policy must be a function -- a non-function value (a config typo) would otherwise
414
+ // be silently skipped, auto-accepting a grantedWithMods certificate the caller meant to vet. Fail at config.
415
+ if (opts.acceptCert != null && typeof opts.acceptCert !== "function") throw _err("cmp/bad-input", "opts.acceptCert must be a function (certDer, info) => boolean");
416
+ // implicitConfirm must be a boolean -- a truthy non-boolean (the string "false", say) would silently REQUEST
417
+ // implicit confirmation and reverse the caller's confirmation policy. Reject a non-boolean at construction.
418
+ if (opts.implicitConfirm != null && typeof opts.implicitConfirm !== "boolean") throw _err("cmp/bad-input", "opts.implicitConfirm must be a boolean");
419
+ // sleep, when supplied, must be a function -- a non-function value would silently fall back to the REAL timer,
420
+ // reversing the caller's intent (a bounded / injected sleeper) into a real-time wait. Reject it at construction.
421
+ if (opts.sleep != null && typeof opts.sleep !== "function") throw _err("cmp/bad-input", "opts.sleep must be a function (delayMs) => Promise");
422
+ // The transport, when supplied, must be a function -- a non-function value passes cmp.transfer's LOCAL url/budget
423
+ // checks and then throws a raw TypeError when the transport is invoked, before any request is sent. That local
424
+ // type error carries none of the typed cmp/* local codes, so the send path would mark the one-shot session
425
+ // consumed on it. Reject it at construction so the transport call can only throw AFTER it has engaged the network.
426
+ if (opts.transport != null && typeof opts.transport !== "function") throw _err("cmp/bad-input", "opts.transport must be a function (url, reqDer, opts) => Promise<{ responseBytes, status }>");
427
+ // acceptCert and implicitConfirm are incompatible: under implicit confirmation the CA treats the certificate
428
+ // as delivered at grant time, so there is no reject leg to honor a veto -- a policy could only produce a
429
+ // misleading rejected verdict for a certificate the CA still considers issued. Refuse the combination up front.
430
+ if (opts.acceptCert != null && opts.implicitConfirm) throw _err("cmp/bad-input", "opts.acceptCert cannot be combined with opts.implicitConfirm -- implicit confirmation leaves no certConf leg to reject on (drop implicitConfirm to vet a grant)");
431
+
432
+ // The verify/validation clock, when supplied, must be a valid Date -- else path.build would only reject it while
433
+ // verifying a response, AFTER the one-shot transaction has already engaged the transport and been consumed.
434
+ if (opts.time != null) guard.time.assertValid(opts.time, _err, "cmp/bad-input", "opts.time (the verify/validation clock)");
435
+ // Poll budgets, validated at construction (NaN / negative / over-max -> cmp/bad-input, never a disabled bound).
436
+ var maxPolls = guard.limits.cap(opts.maxPolls, "opts.maxPolls", DEFAULT_MAX_POLLS, { E: _err, code: "cmp/bad-input", min: 1, max: 1000 });
437
+ var maxTotalWait = guard.limits.cap(opts.maxTotalWait, "opts.maxTotalWait", DEFAULT_MAX_TOTAL_WAIT, { E: _err, code: "cmp/bad-input", min: 0, max: retryAfter.MAX_RETRY_AFTER_SECONDS });
438
+ var sleep = opts.sleep || sleepUtil.sleep; // validated a function (or absent) at construction
439
+
440
+ // Transaction identity: mint ONE 128-bit transactionID (stable for the whole transaction), held here.
441
+ var transactionID = Buffer.from(webcrypto.webcrypto.getRandomValues(new Uint8Array(16)));
442
+ var lastPeerNonce = null; // recipNonce for the NEXT request := the previous response's senderNonce
443
+ var haveResponse = false; // a response has been received -> every subsequent request MUST echo a recipNonce
444
+ var cachedSignerCert = null; // the CA's verified signature-protection signer cert, reused when a later leg omits extraCerts
445
+ var pinnedSignerCert = null, signerPinned = false; // the parsed signer certificate of the FIRST signed response -- every later leg's authenticated sender must bind to it (a boolean flag, so it is pinned exactly once)
446
+ var cachedChain = []; // the VALIDATED signer chain (signer + the intermediates path.build used) from cmp.verify -- INDEPENDENT DER copies, so a later leg rebuilds the signer path from only trusted-path material (not paddable extraCerts) without pinning a response allocation
447
+ var caPubsAccum = []; // the AUTHENTICATED caPubs (issuer certs) accumulated across EVERY ip/cp/kup leg -- a CA may deliver the issuing chain in a `waiting` response and omit it from the eventual grant
448
+ var caPubsSeen = Object.create(null); // byte-identity dedup for caPubsAccum, bounding cross-leg accumulation (below)
449
+ var caPubsBytes = 0; // running total of retained caPubs bytes -- a transaction-wide byte budget (below) bounds it independent of the count cap, since a single certificate can approach the DER limit
450
+ var caPubsWaitingCount = 0; // WAITING-leg entries retained at the FRONT of caPubsAccum -- only these are evictable to fit a grant entry; a grant entry (at the back) is the issued leaf's own chain and is never evicted
451
+ var activeCertReqId = DEFAULT_CERT_REQ_ID; // the certReqId of the in-flight enrollment; echoed in pollReq / certConf
452
+ var expectedRespArm = "ip"; // the cert-response arm the in-flight enrollment must be answered by (RESPONSE_ARM)
453
+ var requestedSpki = null; // the SPKI DER of the key the enrollment requested; the issued cert MUST carry it
454
+ var inFlight = false, completed = false, started = false; // one transaction per session; `started` = a request may have hit the transport
455
+ var transcript = [];
456
+ var transcriptBytes = 0; // running total of RETAINED transcript payload bytes -- bounds a padded-response flood across polling legs (below)
457
+ // The transcript-retention ceiling: TRANSCRIPT_RETAIN_RESPONSES times the effective per-response byte cap. A
458
+ // non-positive / non-finite maxResponseBytes falls back to the default here (a bad value throws in the transport
459
+ // on the first transfer, before any response is recorded) so the ceiling is ALWAYS a positive finite number --
460
+ // a NaN ceiling would make `total > ceiling` perpetually false and silently disable the bound.
461
+ var _perRespCap = (typeof opts.maxResponseBytes === "number" && isFinite(opts.maxResponseBytes) && opts.maxResponseBytes > 0) ? opts.maxResponseBytes : constants.LIMITS.HTTP_MAX_RESPONSE_BYTES;
462
+ var transcriptCap = _perRespCap * TRANSCRIPT_RETAIN_RESPONSES;
463
+ // The caPubs byte budget, proportional to the per-response cap and above one full response, so the GRANT leg's
464
+ // own caPubs (bounded by one response) always fit after evicting waiting entries -- while the total stays bounded
465
+ // (a count-only cap would still admit ~16 GiB of DER-limit-sized certs) independent of the candidate-count cap.
466
+ var caPubsByteBudget = _perRespCap * TRANSCRIPT_RETAIN_RESPONSES;
467
+
468
+ // Sender / recipient routing (RFC 9810 sec. 5.1.1). The sender NAMES the requester: signature protection
469
+ // defaults it to the signer cert's own subject DN; a MAC transaction (no cert) has no established name, so
470
+ // it defaults to the NULL-DN (an empty RDNSequence). The recipient (the CA) defaults to the NULL-DN too --
471
+ // the CA is addressed by the endpoint URL, not by a name we can assert. opts.sender / opts.recipient override.
472
+ var NULL_DN = { directoryName: [] };
473
+ var defaultSender = NULL_DN;
474
+ if (isSig) {
475
+ try {
476
+ var _signerSubject = x509.parse(opts.cert).subject;
477
+ // An EMPTY-subject certificate (RFC 5280 sec. 4.1.2.6) is identified only by its subjectAltName, and a
478
+ // conforming peer binds the sender to that SAN (never to the empty subject). Defaulting the sender to the
479
+ // empty DN would name the requester with a value no peer accepts, so REQUIRE opts.sender for such a cert.
480
+ if (_signerSubject.rdns && _signerSubject.rdns.length > 0) defaultSender = { directoryName: _signerSubject.bytes };
481
+ else if (opts.sender == null) throw _err("cmp/bad-input", "the signature-protection certificate has an empty subject, so it cannot name the request sender -- an empty-subject certificate is identified by its subjectAltName; set opts.sender explicitly (e.g. a subjectAltName identity the CA will bind the sender to)");
482
+ } catch (e) {
483
+ if (e && e.isCmpError) throw e;
484
+ defaultSender = NULL_DN; // a malformed signer cert -> the sender falls back to a NULL-DN (driven by the unparseable-cert construction vector)
485
+ }
486
+ }
487
+
488
+ // The header the session OWNS on every request (transaction identity + routing), plus the protection opts.
489
+ function _baseHeader(fresh) {
490
+ var h = { transactionID: transactionID, senderNonce: fresh };
491
+ // The first request carries no recipNonce; every FOLLOW-UP leg MUST echo the previous response's
492
+ // senderNonce (RFC 9810 sec. 5.1.1). A prior response that omitted its senderNonce leaves nothing to
493
+ // echo -- the anti-replay chain is broken, so fail closed rather than send an unchained request.
494
+ if (haveResponse) {
495
+ if (!Buffer.isBuffer(lastPeerNonce) || lastPeerNonce.length === 0) throw _err("cmp/bad-nonce", "the previous CMP response omitted its senderNonce, so this follow-up request cannot echo it as recipNonce (RFC 9810 sec. 5.1.1)");
496
+ h.recipNonce = lastPeerNonce;
497
+ }
498
+ h.sender = opts.sender != null ? opts.sender : defaultSender;
499
+ h.recipient = opts.recipient != null ? opts.recipient : NULL_DN;
500
+ // Key identifiers the endpoint uses to select a credential -- a PBMAC1 shared secret (senderKID) among several,
501
+ // or the recipient key (recipKID). Propagated on EVERY leg so the CA resolves the same credential throughout.
502
+ if (opts.senderKID != null) h.senderKID = opts.senderKID;
503
+ if (opts.recipKID != null) h.recipKID = opts.recipKID;
504
+ return h;
505
+ }
506
+ function _buildOpts() {
507
+ var o = isSig ? { key: opts.key, cert: opts.cert } : { mac: opts.mac };
508
+ if (opts.extraCerts != null) o.extraCerts = opts.extraCerts;
509
+ if (opts.pss != null) o.pss = opts.pss;
510
+ if (opts.digestAlgorithm != null) o.digestAlgorithm = opts.digestAlgorithm;
511
+ return o;
512
+ }
513
+ function _verifyOpts(fresh, signerCertOverride, extraIntermediates, responseExtra) {
514
+ // Verify the RESPONSE protection + bind it to THIS exchange via the opt-in echo checks. The CA's signer
515
+ // cert is resolved from the response extraCerts (RFC 9483 sec. 3.3); trustAnchors chain it. A MAC response
516
+ // verifies under the shared secret. transactionID + expectRecipNonce fail-close a mismatched response.
517
+ var o = { transactionID: transactionID, expectRecipNonce: fresh };
518
+ if (isMac) o.sharedSecret = opts.mac.secret;
519
+ // Forward trustAnchors to the RESPONSE verify ONLY for the signature flavor -- the MAC branch of cmp.verify
520
+ // rejects them as a stray signature credential. A MAC session's anchors are used solely for issued-cert
521
+ // validation (_validateLeaf), never the MAC response authentication (which the shared secret establishes).
522
+ else if (opts.trustAnchors != null) o.trustAnchors = opts.trustAnchors;
523
+ // Give cmp.verify ONE bounded pool that already contains the signer's OWN delivered issuers, so it needs no
524
+ // room RESERVED for an internal append (no pre-verify count to estimate, no duplicate to over-reserve). Order
525
+ // by priority: the response's extraCerts first (when responseExtra is passed), then the cached signer chain,
526
+ // then the broad caller pool fills to the ceiling -- _boundedPool dedups the whole thing, so a response cert
527
+ // duplicating a caller one spends no slot, and cmp.verify's re-append of the response extraCerts is all
528
+ // duplicates path.build collapses. The signer itself may sit in this pool as a redundant candidate (a slot);
529
+ // rather than try to IDENTIFY it among extraCerts (senderKID can select one that is not first), the caller of
530
+ // _send retries with responseExtra=null (caller pool at the full ceiling) if this response-prioritized pool
531
+ // truncated a needed caller issuer -- the two attempts cover both truncation priorities without identifying the signer.
532
+ var issuers = Array.isArray(responseExtra) ? responseExtra : [];
533
+ var extra = [];
534
+ if (Array.isArray(extraIntermediates)) {
535
+ extra = signerCertOverride == null ? extraIntermediates : extraIntermediates.filter(function (c) {
536
+ return !(Buffer.isBuffer(c) && Buffer.isBuffer(signerCertOverride) && c.equals(signerCertOverride));
537
+ });
538
+ }
539
+ var ints = _boundedPool(issuers.concat(extra), _asCertList(opts.intermediates));
540
+ if (ints.length) o.intermediates = ints;
541
+ if (opts.time != null) o.time = opts.time;
542
+ // An explicit signerCert takes ABSOLUTE precedence in cmp.verify, so it is passed ONLY as a fallback for a
543
+ // response that cannot resolve its own signer -- never by default, else a clustered CA that rotates the
544
+ // protection cert mid-transaction (a valid later cert in that leg's extraCerts) would verify under the wrong key.
545
+ if (signerCertOverride != null) o.signerCert = signerCertOverride;
546
+ return o;
547
+ }
548
+ function _transferOpts() {
549
+ var o = {};
550
+ ["transport", "tls", "headers", "timeout", "maxResponseBytes"].forEach(function (k) { if (opts[k] != null) o[k] = opts[k]; });
551
+ return o;
552
+ }
553
+
554
+ // ONE leg: mint a fresh senderNonce, build+protect the request, transfer it, VERIFY the response BEFORE
555
+ // reading its body (a !valid verdict is a HARD STOP throw), chain the peer nonce, record both directions.
556
+ async function _send(bodySpec, arm) {
557
+ var fresh = Buffer.from(webcrypto.webcrypto.getRandomValues(new Uint8Array(16)));
558
+ var header = Object.assign(_baseHeader(fresh), ENROLL_ARMS[arm] && opts.implicitConfirm ? { generalInfo: [{ infoType: "implicitConfirm" }] } : {});
559
+ var reqDer = await cmp.build({ header: header, body: bodySpec }, _buildOpts());
560
+ _recordTranscript({ direction: "out", arm: arm, bytes: reqDer });
561
+ // The one-shot `started` flag must flip iff a request MAY have reached the transport, so the caller can fix a
562
+ // purely-local config error (url parse, transfer-budget caps, TLS-anchor check) and retry, but a request that
563
+ // reached the CA is never silently replayed under the same transactionID/nonce. cmp.transfer runs those local
564
+ // checks BEFORE it invokes the transport. A CUSTOM transport, though, can itself throw the SAME cmp/* codes as
565
+ // those preflight checks AFTER receiving the request, so the error code cannot distinguish the two -- WRAP it so
566
+ // `engaged` flips the instant cmp.transfer hands it the request (past its preflight). The DEFAULT transport never
567
+ // reuses the preflight codes for its own network errors, so for it those codes reliably mean a pre-send failure.
568
+ var engaged = false;
569
+ var topts = _transferOpts();
570
+ if (opts.transport != null) topts.transport = function (a) { engaged = true; return opts.transport(a); };
571
+ var res;
572
+ try {
573
+ res = await cmp.transfer(opts.url, reqDer, topts);
574
+ } catch (e) {
575
+ var reached = opts.transport != null
576
+ ? engaged
577
+ : !(e && (e.code === "cmp/bad-url" || e.code === "cmp/no-trust-anchors" || e.code === "cmp/bad-input"));
578
+ if (reached) started = true;
579
+ throw e;
580
+ }
581
+ started = true; // the transport returned a response -> the transaction has engaged, the session is consumed
582
+ // The response's OWN deduped extraCerts (the signer + its delivered issuers) -- _verifyOpts folds them into the
583
+ // signer-path pool as priority so cmp.verify needs no reserved room for its internal append. cmp.transfer has
584
+ // already parsed res.responseBytes as a PKIMessage (its cmp.parse gate on every return path), so this cannot throw.
585
+ var responseExtra = _responseExtraCerts(res.responseBytes);
586
+ // The response's OWN issuers, the CACHED chain from an earlier authenticated leg, AND the whole caller pool fit
587
+ // in ONE candidate pool (SESSION_MAX_INTERMEDIATES reserves room below the ceiling), so a single verify sees every
588
+ // candidate -- no priority attempt has to choose between them, and a signer path assembled from any of the three
589
+ // sources builds. cachedChain is included in the PRIMARY (not only the fallback) so a same-identity signer
590
+ // rotation -- a later leg's extraCerts carries the rotated signer B, but B's issuer was delivered only alongside
591
+ // signer A on an earlier leg -- resolves B from its own extraCerts AND chains it via the cached issuer, rather
592
+ // than reporting B untrusted and falling through to a fallback that (forcing signer A's key) cannot verify B's
593
+ // signature. No signer identification is needed (senderKID may name a cert that is not extraCerts[0]); every
594
+ // candidate is simply present. cachedChain is [] before any response has authenticated, so this is inert then.
595
+ var verdict = await cmp.verify(res.responseBytes, _verifyOpts(fresh, null, cachedChain, responseExtra));
596
+ // Fallback: a signature-flavor response's OWN signer resolution can fail FOUR ways that the earlier,
597
+ // authenticated signer still recovers, all because extraCerts is OUTSIDE the protected part and a meddler
598
+ // controls which cert the resolver selects -- (a) NO signer resolved (a later leg omitted extraCerts once the
599
+ // recipient holds the cert, RFC 9483 sec. 3.3) -> signer-cert-not-found; (b) a same-subject decoy with a
600
+ // DIFFERENT key selected first -> protection-failed; (c) a decoy carrying the REAL signer's key but a WRONG
601
+ // subject -> the signature verifies yet the header sender does not bind to it -> sender-mismatch; (d) a decoy
602
+ // carrying the REAL signer's key + identity but chaining to an UNTRUSTED issuer -> verifies yet trust fails
603
+ // -> untrusted-signer. All retry against the cached (already trusted) signer + its chain, whose subject IS the
604
+ // authenticated sender and whose key is fixed. The response's own certs are still preferred on the FIRST
605
+ // attempt, so a clustered CA's legitimately rotated cert wins there; the retry can only rescue a message
606
+ // ACTUALLY signed by the cached signer, never a genuinely tampered / mismatched / untrusted one (it re-runs
607
+ // every gate against the cached cert -- a wrong-key message stays valid:false, a wrong sender stays mismatched).
608
+ // A transaction-integrity failure (transactionID / recipNonce) is NOT retried: it is decoy-independent and a
609
+ // real desync, so the cached signer would fail it identically -- retrying would only mask the diagnostic.
610
+ // The fallback signer is the already-authenticated cached signer, or -- before any response has succeeded --
611
+ // the caller's prebound CA certificate (opts.expectedSender). The latter rescues a FIRST response that omits
612
+ // its own extraCerts because the CA assumes the client already holds its certificate (RFC 9483 sec. 3.3): the
613
+ // signer cannot resolve from an empty extraCerts, yet the exact certificate is locally provisioned.
614
+ var usedCachedFallback = false;
615
+ var fallbackSigner = cachedSignerCert != null ? cachedSignerCert : _expectedSenderDer;
616
+ var recoverable = _isRecoverableVerify(verdict.code);
617
+ if ((verdict.valid !== true || verdict.trusted !== true) && isSig && fallbackSigner != null && recoverable) {
618
+ // A CACHED-signer fallback re-verifies against material a PRIOR leg authenticated, so THIS response's
619
+ // extraCerts are decoy-suspect and must NOT overwrite the cache. A PREBORN fallback (no prior cache, the
620
+ // caller's opts.expectedSender) instead verifies this response's OWN extraCerts against the prebound signer,
621
+ // so they ARE authenticated -- cache them, else a later extraCerts-less leg retries with an empty chain.
622
+ usedCachedFallback = cachedSignerCert != null;
623
+ verdict = await cmp.verify(res.responseBytes, _verifyOpts(fresh, fallbackSigner, cachedChain, responseExtra));
624
+ }
625
+ _recordTranscript({ direction: "in", arm: verdict.body ? verdict.body.arm : null, status: res.status, bytes: res.responseBytes, verdict: { valid: verdict.valid, trusted: verdict.trusted, code: verdict.code || null } });
626
+ if (verdict.valid !== true) throw _err(verdict.code || "cmp/protection-failed", "the CMP response protection did not verify (" + (verdict.reason || "invalid") + ") -- the transaction is NOT advanced", null);
627
+ // Cryptographically valid is NOT enough: the signer (signature flavor) must chain to a supplied trust
628
+ // anchor, or the shared secret (MAC flavor) must match -- both surface as verdict.trusted. A valid-but-
629
+ // untrusted response is an unauthenticated signer; fail closed rather than read a certificate off it.
630
+ if (verdict.trusted !== true) throw _err(verdict.code || "cmp/untrusted-signer", "the CMP response protection verified but its signer is not trusted (it did not chain to a supplied trust anchor) -- the transaction is NOT advanced", null);
631
+ // Bind a SIGNED response to the intended CA IDENTITY, not merely to "any trusted signer". A trust anchor that
632
+ // issues to more than one party would otherwise let an on-path holder of ANY chaining digitalSignature cert
633
+ // sign a forged response under its OWN subject and still pass the trust gate. The binding reuses cmp.verify's
634
+ // OWN sender<->certificate rule (RFC 5280 sec. 7 per-type comparison: a directoryName under the sec. 7.1
635
+ // canonical DN comparison, a dNSName / rfc822Name / URI case-folded, every other type by exact DER) so a
636
+ // re-encoded-but-equivalent DN (PrintableString<->UTF8String) and an empty-subject CA named only by a
637
+ // subjectAltName both compare correctly -- neither a raw byte pin nor a subject-string pin can do that.
638
+ if (isSig && verdict.signer && verdict.header && verdict.header.sender) {
639
+ // (1) opts.expectedSender pinned the CA's signer certificate up front: every signed response's authenticated
640
+ // header sender MUST bind to it, enforced from the FIRST response (so a forged first response is caught too).
641
+ if (_expectedSenderCert && !cmp.senderBoundToCert(verdict.header.sender, _expectedSenderCert)) {
642
+ throw _err("cmp/untrusted-signer", "the response signer does not match the expected CA identity (opts.expectedSender) -- refusing a response from a different trusted signer", null);
643
+ }
644
+ // (2) The FIRST signed response pins the CA certificate; every later leg's authenticated sender MUST bind to
645
+ // it under the same rule -- permitting same-identity certificate/key rotation (the sender identity survives a
646
+ // re-encoding) while rejecting a mid-transaction switch to a different trusted signer.
647
+ if (!signerPinned) { pinnedSignerCert = x509.parse(verdict.signer.cert); signerPinned = true; } // a boolean flag: the pin is set once, never re-initialized per leg
648
+ else if (!cmp.senderBoundToCert(verdict.header.sender, pinnedSignerCert)) {
649
+ throw _err("cmp/untrusted-signer", "the response signer identity changed mid-transaction -- a different trusted signer, not a same-identity certificate rotation; the transaction is NOT advanced", null);
650
+ }
651
+ }
652
+ lastPeerNonce = verdict.senderNonce; // may be absent; _baseHeader enforces its presence before a follow-up leg
653
+ haveResponse = true;
654
+ // Refresh the cached signer + its validated chain material from a response that verified ON ITS OWN OR via the
655
+ // PREBORN (opts.expectedSender) fallback -- in both, its extraCerts IS the chain that established trust (the
656
+ // most recently authenticated cert + the intermediates that chained it; a clustered CA may rotate its
657
+ // protection cert across legs), so a later extraCerts-less leg falls back to the current signer AND can rebuild
658
+ // its path. A response that verified only via the CACHED-signer fallback did NOT authenticate its own extraCerts
659
+ // (which may hold a meddler's decoy or omit the real intermediate); keep the existing cached signer + chain --
660
+ // the material that actually established trust -- rather than overwriting it with that response's untrusted pool.
661
+ if (isSig && !usedCachedFallback && verdict.signer && Buffer.isBuffer(verdict.signer.cert)) {
662
+ var extras = _responseExtraCerts(res.responseBytes); // the response's own delivered extraCerts (bounded)
663
+ // Cache the VALIDATED chain cmp.verify assembled (the signer + the intermediates path.build actually used),
664
+ // NOT the raw extraCerts. extraCerts is UNSIGNED: a peer can pad it with the real signer plus unrelated
665
+ // parseable certificates, and caching that raw list (even newest-first, bounded) would evict the issuer a
666
+ // same-identity rotation relied on -- a subsequent bare leg from the rotated signer would then fail to chain.
667
+ // verdict.signer.chain holds ONLY certificates on the trusted path, so the establishing issuer always
668
+ // survives. Fall back to the delivered extraCerts if a chain is somehow absent (defensive; a trusted verdict
669
+ // always carries one). Only refresh when the response delivered its OWN extraCerts (an extraCerts-less leg
670
+ // keeps the prior chain).
671
+ if (extras.length) {
672
+ cachedSignerCert = Buffer.from(verdict.signer.cert); // COPY: an extraCerts-resolved signer.cert is a slice of the response, so caching it raw pins that allocation (the chain is copied by cmp.verify; keep this one consistent)
673
+ cachedChain = (Array.isArray(verdict.signer.chain) && verdict.signer.chain.length) ? verdict.signer.chain : extras;
674
+ }
675
+ }
676
+ return verdict;
677
+ }
678
+
679
+ // Classify a verified response into a transaction transition. For ip/cp/kup, read the FIRST CertResponse
680
+ // status; error carries a terminal PKIStatusInfo; pollRep drives the loop; any other arm is unexpected.
681
+ function _classify(verdict) {
682
+ var body = verdict.body || {};
683
+ var arm = body.arm;
684
+ if (arm === expectedRespArm) {
685
+ // Select the CertResponse for OUR request by its certReqId (RFC 9810 sec. 5.3.4) -- never blindly
686
+ // response[0], so a batch / misrouted response for a different request id is not read as ours.
687
+ var responses = (body.decoded && body.decoded.response) || [];
688
+ var resp = null;
689
+ for (var ri = 0; ri < responses.length; ri++) { if (_certReqIdEq(responses[ri].certReqId, activeCertReqId)) { resp = responses[ri]; break; } }
690
+ if (!resp) return { state: "unexpected", reason: "a " + arm + " carried no CertResponse for certReqId " + activeCertReqId };
691
+ var code = resp.status && resp.status.status ? resp.status.status.code : null;
692
+ var isGrantLeg = _isGranted(code);
693
+ // Accumulate the AUTHENTICATED caPubs of EVERY ip/cp/kup leg (waiting AND granting): a CA may deliver the
694
+ // issuing chain in a `waiting` response and omit it from the grant, so the leaf's issuer must survive the poll.
695
+ // Bound + validate + dedup the caPubs AS ACCUMULATED, not only when _finish is reached: an authenticated CA
696
+ // can return `waiting` up to the poll budget with large or repeated caPubs, so without a cross-leg cap this
697
+ // array would retain unbounded material before validation. Enforce a count AND a byte budget; a byte-identical
698
+ // repeat is dropped; a non-X.509 entry is REJECTED (fail-closed -- the leaf validation would otherwise defer
699
+ // it to _finish). The GRANT leg's caPubs are the issued leaf's OWN chain (highest priority): they EVICT the
700
+ // oldest WAITING entries to fit ALL of them within the budget, so no fixed reserve can starve the leaf's own
701
+ // required issuer no matter how many waiting caPubs preceded it (a caPubs field carries no MAX_EXTRA_CERTS cap).
702
+ if (body.decoded && Array.isArray(body.decoded.caPubs)) body.decoded.caPubs.forEach(function (c) {
703
+ if (!Buffer.isBuffer(c)) return;
704
+ var k = c.toString("base64");
705
+ if (caPubsSeen[k]) {
706
+ // Already retained (from a waiting leg). On the GRANT leg it is the issued leaf's OWN material -- PROMOTE
707
+ // it to the newest position so a LATER grant entry's eviction cannot drop it while its grant occurrence is
708
+ // skipped as a duplicate. (A waiting-leg duplicate keeps its position; only the grant confers priority.)
709
+ if (isGrantLeg) { for (var pi = 0; pi < caPubsAccum.length; pi++) { if (caPubsAccum[pi].toString("base64") === k) { caPubsAccum.push(caPubsAccum.splice(pi, 1)[0]); if (pi < caPubsWaitingCount) caPubsWaitingCount--; break; } } }
710
+ return;
711
+ }
712
+ try { x509.parse(c); } catch (e) { throw _err("cmp/bad-cert-response", "an authenticated caPubs entry is not a valid X.509 certificate", e); }
713
+ if (isGrantLeg) {
714
+ // Evict ONLY the WAITING entries at the front to fit this grant entry -- never an earlier GRANT entry (the
715
+ // issued leaf's own chain). Once no waiting entries remain, this grant entry is dropped by the cap below.
716
+ while (caPubsWaitingCount > 0 && (caPubsAccum.length >= CAPUBS_MAX || caPubsBytes + c.length > caPubsByteBudget)) {
717
+ var evicted = caPubsAccum.shift();
718
+ caPubsBytes -= evicted.length;
719
+ caPubsWaitingCount--;
720
+ delete caPubsSeen[evicted.toString("base64")]; // keep the dedup set == the retained set, so a grant re-delivering an evicted issuer (also seen in a waiting leg) can re-add it
721
+ }
722
+ }
723
+ if (caPubsAccum.length >= CAPUBS_MAX || caPubsBytes + c.length > caPubsByteBudget) return; // no waiting entry left to evict (a grant would evict a grant) OR over budget -> drop
724
+ caPubsSeen[k] = 1;
725
+ caPubsBytes += c.length;
726
+ caPubsAccum.push(Buffer.from(c)); // COPY out of the parser's subarray -- retaining the slice would pin the whole multi-MB response buffer, so caPubsBytes (the cert length) would not bound the real allocation across polls
727
+ if (!isGrantLeg) caPubsWaitingCount++; // a waiting entry joins the evictable front region
728
+ });
729
+ if (isGrantLeg) return { state: "granted", resp: resp, code: code };
730
+ if (code === 3) return { state: "waiting", resp: resp };
731
+ if (code === 2) return { state: "rejected", resp: resp };
732
+ return { state: "unexpected", reason: "a " + arm + " CertResponse status code " + code + " has no transition" };
733
+ }
734
+ if (arm === "error") return { state: "rejected", status: body.decoded && body.decoded.pKIStatusInfo };
735
+ if (arm === "pollRep") return { state: "pollRep", entries: body.decoded };
736
+ if (arm === "pkiconf") return { state: "pkiconf" };
737
+ return { state: "unexpected", reason: "response arm " + JSON.stringify(arm) + " has no transition in an enrollment transaction" };
738
+ }
739
+
740
+ function _statusOf(resp) { return resp && resp.status ? resp.status : null; }
741
+
742
+ // A granted implicitConfirm in the RESPONSE header's generalInfo (RFC 9810 sec. 5.1.1.1) -- classified by
743
+ // the IMMUTABLE id-it OID, never the mutable display name.
744
+ function _implicitConfirmGranted(header) {
745
+ var gi = header && header.generalInfo;
746
+ if (!Array.isArray(gi)) return false;
747
+ for (var i = 0; i < gi.length; i++) { if (gi[i] && gi[i].type === OID_IMPLICIT_CONFIRM) return true; }
748
+ return false;
749
+ }
750
+
751
+ // Append a transcript entry, bounding the RETAINED payload bytes transaction-wide. A polling enrollment runs
752
+ // many legs and an on-path party can pad every otherwise-valid signed response with unsigned extraCerts up to
753
+ // the per-response cap; retaining each full payload would multiply that cap across legs into memory exhaustion.
754
+ // Once the running total crosses the ceiling, the entry keeps its metadata + `byteLength` but drops the payload
755
+ // (`bytes: null, truncated: true`) so the internal reference is released to GC and the transcript stays a
756
+ // fixed-size diagnostic. Normal kilobyte messages never reach the ceiling, so the payloads are retained intact.
757
+ function _recordTranscript(entry) {
758
+ var len = Buffer.isBuffer(entry.bytes) ? entry.bytes.length : 0;
759
+ if (transcriptBytes + len > transcriptCap) {
760
+ entry.byteLength = len; entry.bytes = null; entry.truncated = true;
761
+ } else {
762
+ transcriptBytes += len;
763
+ }
764
+ transcript.push(entry);
765
+ }
766
+
767
+ // A defensive SNAPSHOT of the transcript: a fresh array of shallow-copied entries with copied byte Buffers, so
768
+ // freezing or mutating the returned value cannot break the internal transcript (_send keeps appending to it)
769
+ // nor mutate a Buffer still shared with an in-flight request/response body.
770
+ function _transcriptSnapshot() {
771
+ return transcript.map(function (e) {
772
+ var c = Object.assign({}, e);
773
+ if (Buffer.isBuffer(c.bytes)) c.bytes = Buffer.from(c.bytes);
774
+ if (c.verdict) c.verdict = Object.assign({}, c.verdict);
775
+ return c;
776
+ });
777
+ }
778
+ function _terminal(outcome, extra) {
779
+ // Every terminal outcome is reached only through _send, which throws unless the response was both valid
780
+ // AND trusted -- so a returned verdict is always authenticated (trusted:true), surfaced for the caller.
781
+ return Object.assign({
782
+ outcome: outcome, certificate: null, chain: [], status: null, trusted: true,
783
+ confirmed: false, implicitConfirm: false, transactionID: Buffer.from(transactionID), // a copy: mutating it must not corrupt the session identity
784
+ polls: 0, transcript: _transcriptSnapshot(),
785
+ }, extra);
786
+ }
787
+
788
+ // The bounded pollReq/pollRep loop (RFC 9810 sec. 5.3.22): sleep >= checkAfter each round, re-classify a
789
+ // non-pollRep response as an initial response, END as poll-timeout when maxPolls / maxTotalWait is hit.
790
+ async function _pollLoop(lastWaitingResp) {
791
+ var polls = 0, waited = 0, lastStatus = _statusOf(lastWaitingResp);
792
+ for (;;) {
793
+ if (polls >= maxPolls || waited > maxTotalWait) return { timeout: true, polls: polls, status: lastStatus };
794
+ var verdict = await _send({ pollReq: [{ certReqId: activeCertReqId }] }, "pollReq");
795
+ polls += 1;
796
+ var t = _classify(verdict);
797
+ if (t.state === "pollRep") {
798
+ // Select the pollRep entry for OUR request by certReqId (RFC 9810 sec. 5.3.22) -- never entry zero,
799
+ // so an unrelated entry's checkAfter cannot stall us and a pollRep that never mentions our request
800
+ // (its entry absent) is a misrouted response, not a silent zero-delay re-poll.
801
+ var entries = t.entries || [];
802
+ var entry = null;
803
+ for (var pe = 0; pe < entries.length; pe++) { if (_certReqIdEq(entries[pe].certReqId, activeCertReqId)) { entry = entries[pe]; break; } }
804
+ if (!entry) return { done: { state: "unexpected", reason: "a pollRep carried no entry for certReqId " + activeCertReqId }, polls: polls, header: verdict.header };
805
+ var checkAfter = typeof entry.checkAfter === "number" ? entry.checkAfter : 0;
806
+ waited += checkAfter;
807
+ if (waited > maxTotalWait) return { timeout: true, polls: polls, status: lastStatus };
808
+ // Do NOT sleep after the FINAL permitted poll: this pollRep answered poll number maxPolls, so the
809
+ // budget is already spent -- sleeping its checkAfter (up to a year) before the loop-top count check
810
+ // would defeat the poll-count bound. Detect exhaustion here, before scheduling the delay.
811
+ if (polls >= maxPolls) return { timeout: true, polls: polls, status: lastStatus };
812
+ await sleep(checkAfter * constants.TIME.seconds(1));
813
+ continue;
814
+ }
815
+ if (t.state === "waiting") { lastStatus = _statusOf(t.resp); continue; } // still processing -> keep polling
816
+ // granted / rejected / unexpected -> advance out of the loop, carrying the GRANTING response's header
817
+ // (an implicitConfirm grant / a fresh nonce lives in THIS response, not the initial waiting one).
818
+ return { done: t, polls: polls, header: verdict.header };
819
+ }
820
+ }
821
+
822
+ // Confirm delivery (RFC 9810 sec. 5.1.1.1 / 5.3.18): if implicitConfirm was granted, END; else send a
823
+ // certConf (certHash under the cert's signature hash) and require a verified pkiConf, else cmp/bad-confirmation.
824
+ // `info` carries the grant status so an acceptance policy can veto a grantedWithMods certificate.
825
+ async function _confirm(certDer, header, info) {
826
+ // Honor an implicitConfirm grant ONLY when the caller REQUESTED it (opts.implicitConfirm): implicit
827
+ // confirmation is negotiated (RFC 9810 sec. 5.1.1.1), so an UNSOLICITED implicitConfirm from a nonconforming
828
+ // server is ignored -- the session performs the explicit certConf the caller expects. No acceptance policy
829
+ // runs here: acceptCert + implicitConfirm is refused at construction (there is no reject leg to honor a veto).
830
+ if (opts.implicitConfirm && _implicitConfirmGranted(header)) return { confirmed: true, implicit: true };
831
+ // Consult the caller's acceptance policy before the explicit confirmation. A grantedWithMods response (RFC
832
+ // 9810 sec. 5.2.3) carries a certificate the CA CHANGED (subject / validity / extensions), and an accepting
833
+ // certConf is irrevocable, so the caller may inspect + veto it here. The policy MUST return true to accept;
834
+ // anything else is a veto (a thrown rejection is NOT swallowed -- it propagates). No policy accepts (default).
835
+ var accept = true;
836
+ if (typeof opts.acceptCert === "function") accept = (await opts.acceptCert(Buffer.from(certDer), info)) === true;
837
+ var h = _certConfHash(certDer);
838
+ var certHash = Buffer.from(await webcrypto.webcrypto.subtle.digest(h.digest, certDer));
839
+ var cs = { certHash: certHash, certReqId: activeCertReqId };
840
+ // A veto sends a REJECTING certConf (CertStatus.statusInfo status rejection, RFC 9810 sec. 5.3.18) so the CA
841
+ // learns the EE declined and does not treat the certificate as delivered; an acceptance omits statusInfo.
842
+ if (!accept) cs.statusInfo = { status: 2, statusString: ["the enrolling client rejected the issued certificate"] };
843
+ if (h.hashAlg) cs.hashAlg = h.hashAlg; // declare the hash for a sig alg whose OID does not convey it (PSS / EdDSA / ML-DSA)
844
+ var verdict = await _send({ certConf: [cs] }, "certConf");
845
+ if (!verdict.body || verdict.body.arm !== "pkiconf") throw _err("cmp/bad-confirmation", "expected a pkiConf acknowledgement to the certConf but got " + JSON.stringify(verdict.body && verdict.body.arm) + " (RFC 9810 sec. 5.3.18)");
846
+ return { confirmed: accept, implicit: false, rejected: !accept };
847
+ }
848
+
849
+ function _leafOf(resp) {
850
+ var ckp = resp && resp.certifiedKeyPair;
851
+ var cert = ckp && ckp.certificate;
852
+ if (!Buffer.isBuffer(cert)) throw _err("cmp/unexpected-arm", "a granted CertResponse carried no plain issued certificate (an encryptedCert form is out of enrollment v1 scope) (RFC 9810 sec. 5.3.4)");
853
+ // A central-key-generation privateKey (the CA generated the key, delivered encrypted) is out of a
854
+ // client-key enrollment session's scope: the caller submitted the key + its proof of possession, so the
855
+ // session would otherwise confirm a certificate whose private key it discarded -- a result the caller cannot use.
856
+ if (ckp.privateKey != null) throw _err("cmp/unexpected-arm", "a granted CertResponse carried a server-generated privateKey (central key generation is out of enrollment v1 scope; a session enrolls a client-generated key) (RFC 9810 sec. 5.3.4)");
857
+ // The CMP parser surfaces certifiedKeyPair.certificate as opaque bytes (any non-empty SEQUENCE); a
858
+ // non-conformant / hostile CA could deliver bytes that are not a real X.509 certificate. Validate them
859
+ // BEFORE confirming, so the session never returns outcome:issued with a value pki.schema.x509.parse rejects.
860
+ var parsed;
861
+ try { parsed = x509.parse(cert); }
862
+ catch (e) { throw _err("cmp/bad-cert-response", "the granted CertResponse's certificate is not a valid X.509 certificate", e); }
863
+ // For a client-generated-key enrollment, the issued cert MUST carry the key the request submitted -- else
864
+ // it is a misrouted certificate the caller holds no private key for. Compare the KEY identity (alg OID +
865
+ // key bits), not the raw SPKI bytes, so an equivalent re-encoding (rsaEncryption NULL vs omitted) matches.
866
+ if (requestedSpki != null && _spkiKeyIdentity(parsed.subjectPublicKeyInfo.bytes) !== _spkiKeyIdentity(requestedSpki)) {
867
+ throw _err("cmp/bad-cert-response", "the issued certificate's public key does not match the requested key -- a misrouted certificate the caller has no private key for");
868
+ }
869
+ return cert;
870
+ }
871
+
872
+ // The SPKI DER the enrollment requested, for the issued-cert key match: an ir/cr/kur certTemplate.publicKey
873
+ // (a SPKI Buffer), or a p10cr CSR's subjectPublicKeyInfo. null when the form is not a plain SPKI/CSR (the
874
+ // build path validates the key separately) or the CSR is unparseable (which fails at the build boundary).
875
+ function _extractRequestedSpki(arm, armSpec) {
876
+ if (arm === "p10cr") {
877
+ if (!Buffer.isBuffer(armSpec) && !(armSpec instanceof Uint8Array)) return null;
878
+ try { return csr.parse(armSpec).subjectPublicKeyInfo.bytes; }
879
+ catch (_e) { /* allow:swallow-unverified an unparseable p10cr CSR fails closed at the cmp.build boundary; the key-match is simply not applied to a request that never sends */ return null; }
880
+ }
881
+ var pk = armSpec && armSpec.certTemplate ? armSpec.certTemplate.publicKey : null;
882
+ if (Buffer.isBuffer(pk)) return pk;
883
+ if (pk instanceof Uint8Array) return Buffer.from(pk);
884
+ return null;
885
+ }
886
+
887
+ // Validate the issued leaf's SIGNATURE + chain (pki.schema.x509.parse is structural only, so a corrupted
888
+ // signature would otherwise be confirmed) whenever trust anchors are supplied. A signature session always has
889
+ // them (required); a MAC session validates the issued cert ONLY when the caller opts in with anchors -- the
890
+ // response MAC authenticates the exchange, NOT the embedded X.509 signature, so without anchors an invalid-sig
891
+ // MAC-issued cert would otherwise be confirmed. The pool prioritizes the AUTHENTICATED, leaf-relevant caPubs
892
+ // (the CA's OWN issuer chain for this leaf) as the base -- never evicted by a caller pool that fills the
893
+ // ceiling -- then the caller's intermediates, then the cached protection-signer chain, deduped + ceiling-bounded.
894
+ async function _validateLeaf(leaf, caPubs) {
895
+ if (_engine == null || opts.trustAnchors == null) return;
896
+ var caPubsList = [];
897
+ if (Array.isArray(caPubs)) caPubs.forEach(function (c) { if (Buffer.isBuffer(c)) caPubsList.push(c); });
898
+ var added = _asCertList(opts.intermediates); // the caller pool (config-bounded to SESSION_MAX_INTERMEDIATES)
899
+ if (Buffer.isBuffer(cachedSignerCert)) added.push(cachedSignerCert); // the CMP signer MAY be the issued leaf's issuer (a combined CA)
900
+ // cachedChain holds the VALIDATED signer chain from cmp.verify as independent DER copies (or delivered
901
+ // extraCerts DER on the defensive fallback) -- path.build's coerceCert parses each, so add them directly.
902
+ cachedChain.forEach(function (c) { if (Buffer.isBuffer(c)) added.push(c); });
903
+ // ONE candidate pool holds everything: the AUTHENTICATED caPubs (the CA's OWN issuer chain for this leaf) as the
904
+ // priority base -- never evicted -- then the whole caller pool + cached signer chain. All fit under the ceiling
905
+ // (caPubs <= CAPUBS_MAX, cached chain <= MAX_EXTRA_CERTS, caller <= SESSION_MAX_INTERMEDIATES = ceiling minus
906
+ // both), so a path assembled from the caPubs AND a late caller intermediate builds in this single attempt.
907
+ var pool = _boundedPool(caPubsList, added);
908
+ var res;
909
+ try { res = await _engine.build(leaf, { trustAnchors: _asCertList(opts.trustAnchors), intermediates: pool, validate: true, time: opts.time != null ? opts.time : new Date() }); }
910
+ catch (e) {
911
+ if (e.code === "path/bad-input") throw _err("cmp/bad-input", "invalid trust / validation options for issued-certificate validation: " + (e.message || e), e);
912
+ // path.build THROWS path/no-path when the issued leaf cannot chain to a supplied anchor -- a CA that grants a
913
+ // certificate whose issuer it never delivered. Re-type it to the domain error so the transaction fails closed
914
+ // before confirmation, never leaking a path/* code.
915
+ throw _err("cmp/bad-cert-response", "the issued certificate could not be validated to a supplied trust anchor: " + (e.message || e), e);
916
+ }
917
+ if (!res || res.valid !== true) throw _err("cmp/bad-cert-response", "the issued certificate did not validate to a supplied trust anchor (its signature or chain is invalid) (RFC 5280 sec. 6.1)");
918
+ }
919
+
920
+ async function _finish(granted, header) {
921
+ var leaf = _leafOf(granted.resp);
922
+ // The returned chain is the issued leaf followed by the authenticated caPubs accumulated across the WHOLE
923
+ // transaction (the issuer certs the CA delivered on any ip/cp/kup leg, RFC 9810 sec. 5.3.4) -- surfaced so a
924
+ // caller whose intermediate arrived only on a `waiting` leg can still assemble the full chain. They are chain
925
+ // material, NOT trust anchors. The CMP parser surfaces each caPubs entry as a raw sequence, so validate every
926
+ // one as X.509 BEFORE the certConf acknowledges the grant -- the returned chain must never carry bytes
927
+ // pki.schema.x509.parse rejects -- and deduplicate a chain re-delivered across the waiting + granting legs.
928
+ var chain = [leaf], seenChain = Object.create(null);
929
+ caPubsAccum.forEach(function (c) {
930
+ if (!Buffer.isBuffer(c)) return;
931
+ try { x509.parse(c); }
932
+ catch (e) { throw _err("cmp/bad-cert-response", "a caPubs certificate in the granting response is not a valid X.509 certificate", e); }
933
+ var k = _certIdentity(c);
934
+ if (k != null && seenChain[k]) return;
935
+ if (k != null) seenChain[k] = 1;
936
+ chain.push(c);
937
+ });
938
+ await _validateLeaf(leaf, caPubsAccum); // verify the leaf's signature + chain BEFORE confirming it
939
+ var info = { status: PKI_STATUS_NAMES[granted.code] || granted.code, grantedWithMods: granted.code === 1 };
940
+ var conf = await _confirm(leaf, header, info);
941
+ // Surface COPIES of the issued certificate + chain, not the internal Buffers (slices of the response bytes),
942
+ // so a caller mutating the returned value cannot reach back into session state -- consistent with the
943
+ // transactionID + transcript defensive copies.
944
+ var certOut = Buffer.from(leaf), chainOut = chain.map(function (c) { return Buffer.from(c); });
945
+ // A caller acceptance-policy veto (typically of a grantedWithMods certificate) is a terminal `rejected`
946
+ // outcome that still surfaces the certificate the caller inspected -- the accepting certConf was replaced by
947
+ // a rejecting one (explicit path) so the CA does not treat it as delivered.
948
+ if (conf.rejected) {
949
+ return _terminal("rejected", { certificate: certOut, chain: chainOut, status: _statusOf(granted.resp), polls: granted.polls || 0 });
950
+ }
951
+ return _terminal("issued", {
952
+ certificate: certOut, chain: chainOut, status: _statusOf(granted.resp),
953
+ confirmed: conf.confirmed, implicitConfirm: conf.implicit, polls: granted.polls || 0,
954
+ });
955
+ }
956
+
957
+ // The transaction driver: build the initial request arm, send it, then poll / confirm / terminate.
958
+ async function enroll(request) {
959
+ // One transaction per session (RFC 9810: one transactionID per transaction). A second or concurrent
960
+ // enroll would reuse this transaction's transactionID / nonce chain / certReqId -- a replay or a corrupted
961
+ // interleave -- so it is refused. The request-shape checks run BEFORE the transaction is marked in-flight,
962
+ // so a malformed-request call does not consume the session and the caller may retry with a valid request.
963
+ if (completed || inFlight) throw _err("cmp/bad-input", "this pki.cmp.session transaction is already " + (completed ? "completed" : "in flight") + "; create a new session per enrollment (RFC 9810 sec. 5.1.1: one transactionID per transaction)");
964
+ if (!request || typeof request !== "object" || Buffer.isBuffer(request)) throw _err("cmp/bad-input", "enroll(request) requires a body spec object { ir | cr | kur | p10cr }");
965
+ var arms = Object.keys(request).filter(function (k) { return request[k] != null; });
966
+ if (arms.length !== 1 || !ENROLL_ARMS[arms[0]]) {
967
+ throw _err("cmp/bad-input", "enroll(request) must carry EXACTLY ONE enrollment arm (ir / cr / kur / p10cr)");
968
+ }
969
+ // A batched CRMF request ({ messages: [...] }) asks for several certificates in one message; this session
970
+ // drives ONE request (single transactionID, one certReqId, one certConf), so a batch is refused rather than
971
+ // silently confirming only the first CertResponse and leaving the rest unprocessed. Submit one per session.
972
+ var armSpec = request[arms[0]];
973
+ if (armSpec && typeof armSpec === "object" && !Buffer.isBuffer(armSpec) && armSpec.messages != null) {
974
+ throw _err("cmp/bad-input", "a batched CRMF request ({ messages: [...] }) is not supported by a session -- submit one certificate request per pki.cmp.session");
975
+ }
976
+ // The session enrolls a CLIENT-submitted key: an ir / cr / kur MUST carry certTemplate.publicKey (a p10cr
977
+ // carries it in the CSR). A keyless request (e.g. raVerified POP with no publicKey, or central key
978
+ // generation) is refused -- else the session would confirm an arbitrary issued key it can neither match
979
+ // to a submitted key nor return a private key for. Checked before the transaction engages the transport.
980
+ var reqSpki = _extractRequestedSpki(arms[0], armSpec);
981
+ if (arms[0] !== "p10cr" && reqSpki == null) {
982
+ throw _err("cmp/bad-input", "an ir / cr / kur enrollment must submit certTemplate.publicKey -- a session enrolls a client-generated key (a raVerified keyless request or central key generation is not supported)");
983
+ }
984
+ // The session proves possession by SIGNING the CRMF proof of possession with the requested key (opts.key for
985
+ // a signature session, the arm-local `key` for a MAC session). A raVerified / other non-signature POP override
986
+ // would make crmf.build emit THAT mode and skip the signature entirely -- no requester proof of possession --
987
+ // so refuse it: it would also bypass the MAC key requirement below (a present key is ignored under raVerified).
988
+ if (arms[0] !== "p10cr" && armSpec != null && armSpec.pop != null && armSpec.pop.type != null && armSpec.pop.type !== "signature") {
989
+ throw _err("cmp/bad-input", "a session ir / cr / kur proves possession by signing the CRMF proof of possession; a non-signature POP mode (" + JSON.stringify(armSpec.pop.type) + ") is not supported (RFC 4211 sec. 4)");
990
+ }
991
+ // A signature session signs the CRMF proof of possession with opts.key; a PBMAC1 session has NO signing key,
992
+ // so an ir / cr / kur under MAC protection MUST carry the requested key's private half as the arm-local
993
+ // `key`. Without it crmf.build emits NO proof of possession (RFC 4211 sec. 4) and a POP-enforcing CA rejects
994
+ // the request. crmf.build then verifies the POP signature against certTemplate.publicKey, so a WRONG key
995
+ // fails at build -- here we only require the key is present. (A p10cr proves possession via the CSR signature.)
996
+ if (isMac && arms[0] !== "p10cr" && (armSpec == null || armSpec.key == null)) {
997
+ throw _err("cmp/bad-input", "a MAC-protected ir / cr / kur must carry the requested key's private half as `key` for the CRMF proof of possession -- a signature session reuses opts.key, but a PBMAC1 session has no signing key, so the request would carry no proof of possession (RFC 4211 sec. 4)");
998
+ }
999
+ inFlight = true;
1000
+ try {
1001
+ expectedRespArm = RESPONSE_ARM[arms[0]]; // ir->ip, cr/p10cr->cp, kur->kup: the arm this request must be answered by
1002
+ // The certReqId the session echoes in pollReq / certConf and matches in every CertResponse: the caller's
1003
+ // CRMF request id (ir / cr / kur) when supplied, else the arm default -- a p10cr has no CRMF id, so a
1004
+ // conforming cp identifies it with the -1 sentinel (RFC 9483), while ir / cr / kur default to 0.
1005
+ var cid = (typeof armSpec === "object" && !Buffer.isBuffer(armSpec)) ? armSpec.certReqId : undefined;
1006
+ activeCertReqId = _normalizeCertReqId(cid, arms[0] === "p10cr" ? P10CR_CERT_REQ_ID : DEFAULT_CERT_REQ_ID);
1007
+ requestedSpki = reqSpki; // the key the issued cert must carry (RFC 4211 key match)
1008
+ var initial = await _send(request, arms[0]);
1009
+ var t = _classify(initial);
1010
+ var grantHeader = initial.header; // the header of the response that produced the terminal classification `t`
1011
+ var pollCount = 0;
1012
+ if (t.state === "waiting") {
1013
+ var polled = await _pollLoop(t.resp);
1014
+ pollCount = polled.polls;
1015
+ if (polled.timeout) return _terminal("poll-timeout", { status: polled.status, polls: pollCount });
1016
+ t = polled.done;
1017
+ grantHeader = polled.header; // the GRANTING (post-poll) response's header -- where an implicitConfirm grant lives
1018
+ t.polls = pollCount;
1019
+ }
1020
+ if (t.state === "granted") { var r = await _finish({ resp: t.resp, polls: pollCount, code: t.code }, grantHeader); r.polls = pollCount; return r; }
1021
+ if (t.state === "rejected") return _terminal("rejected", { status: t.status || _statusOf(t.resp), polls: pollCount });
1022
+ throw _err("cmp/unexpected-arm", t.reason || "the enrollment transaction reached an unexpected state");
1023
+ } finally {
1024
+ inFlight = false;
1025
+ // Consume the session only if a request may have reached the transport. A purely LOCAL failure (a build
1026
+ // error before the first transfer -- e.g. a missing CRMF template) leaves the session retryable.
1027
+ completed = started;
1028
+ }
1029
+ }
1030
+
1031
+ return {
1032
+ enroll: enroll,
1033
+ get transactionID() { return Buffer.from(transactionID); }, // a copy: a caller mutating it must not desync the transaction
1034
+ get transcript() { return _transcriptSnapshot(); }, // a defensive snapshot; never the mutable internal array
1035
+ };
1036
+ }
1037
+
1038
+ module.exports = {
1039
+ build: cmp.build,
1040
+ transfer: cmp.transfer,
1041
+ wellKnownUrl: cmp.wellKnownUrl,
1042
+ verify: cmp.verify,
1043
+ session: session,
1044
+ setEngine: setEngine, // @internal -- path-validate injects the path build/validate engine (issued-leaf validation)
1045
+ };