@blamejs/pki 0.6.50 → 0.6.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## v0.6.51 — 2026-09-07
8
+
9
+ A CMP message that names one key and is protected by another no longer verifies.
10
+
11
+ ### Added
12
+
13
+ - pki.cmp.verify checks the senderKID of a MAC-protected message against the commonName of its sender field, which is how such a message names the shared secret it was protected with (RFC 9483 section 4.1.5). A senderKID naming another entity, or a sender carrying no commonName to match, is refused with cmp/bad-sender-kid. An anonymous sender is the exception the base protocol names: where the sender is the NULL-DN a requester uses before it knows its own name, the senderKID is a reference number rather than a name (RFC 9810 section 5.1.1), so it is carried and not compared. A KEM-protected message's senderKID is likewise carried and not compared, since no specification says what it names there.
14
+ - pki.cmp.verify refuses an opts.time that is not a Date with cmp/bad-input, at the door, rather than letting whichever check reads it first throw a bare TypeError.
15
+ - pki.cmp.verify takes opts.messageTimeTolerance, the accepted messageTime skew in seconds. RFC 9483 section 3.5 requires a present messageTime to be close to the receiver's current time but leaves the threshold to the use case, so stating a window turns the check on and a message outside it in either direction is a cmp/bad-message-time verdict. A clock runs fast as readily as slow, so a message dated ahead of the receiver is judged the same as one dated behind it. Without the option the messageTime is not judged, and a message carrying none is unaffected.
16
+
17
+ ### Changed
18
+
19
+ - A caller that passes opts.signerCert for a message whose senderKID does not match that certificate now receives a cmp/bad-sender-kid verdict where the message previously verified. A sender that sets senderKID to its certificate's subjectKeyIdentifier, which is what the field is for, is unaffected.
20
+
21
+ ### Fixed
22
+
23
+ - pki.cmp.verify holds a declared senderKID to the key that actually verified the protection, on every path (RFC 9483 section 3.5). A certificate passed as opts.signerCert is now checked against the header's senderKID exactly as one selected from extraCerts already was, so a message naming one key identifier and signed under another is refused with cmp/bad-sender-kid instead of verifying. A signer certificate carrying no subjectKeyIdentifier offers nothing for a declared identifier to name and cannot satisfy one; a message declaring no senderKID is unaffected, since the rule is conditioned on the field being present.
24
+ - A message whose extraCerts carry no certificate matching the declared senderKID reports cmp/bad-sender-kid rather than cmp/signer-cert-not-found. The two failures are different: nothing was offered, versus nothing offered carries the identifier the message declares. Reporting them apart keeps one defect from carrying two codes depending on how the certificate would have arrived. pki.cmp.session treats the new code the same way it treated the old one, so a transaction whose response declares a signer the response did not carry still falls back to the certificate the session already holds, and that certificate is put through the same identifier check.
25
+
7
26
  ## v0.6.50 — 2026-09-07
8
27
 
9
28
  A client whose key can only establish secrets can now authenticate its CMP messages with that key.
@@ -201,8 +201,14 @@ var CAPUBS_MAX = 2 * MAX_EXTRA_CERTS;
201
201
  var SESSION_MAX_INTERMEDIATES = constants.LIMITS.PATH_BUILD_MAX_CANDIDATES - CAPUBS_MAX - MAX_EXTRA_CERTS;
202
202
  var SESSION_MAX_INTERMEDIATES_MAC = constants.LIMITS.PATH_BUILD_MAX_CANDIDATES - CAPUBS_MAX;
203
203
  function _isRecoverableVerify(code) {
204
+ /** @internal `cmp/bad-sender-kid` belongs here for the same reason `cmp/signer-cert-not-found` does:
205
+ * a response whose extraCerts carry no certificate matching the declared key identifier is one this
206
+ * session may already hold the signer for. Retrying is safe because the fallback certificate is put
207
+ * through the same identifier check, so the retry can only succeed on a certificate the message
208
+ * actually names. */
204
209
  return code === "cmp/signer-cert-not-found" || code === "cmp/protection-failed" ||
205
- code === "cmp/sender-mismatch" || code === "cmp/untrusted-signer";
210
+ code === "cmp/sender-mismatch" || code === "cmp/untrusted-signer" ||
211
+ code === "cmp/bad-sender-kid";
206
212
  }
207
213
  function _responseExtraCerts(responseBytes) {
208
214
  var extra = schemaCmp.parse(responseBytes).extraCerts;
package/lib/cmp-verify.js CHANGED
@@ -52,6 +52,7 @@ var _certExtDecoders = pkix.certExtensionDecoders(NS).byOid;
52
52
  var KNOWN_VERIFY_OPTS = intrinsic.assign(intrinsic.create(null), {
53
53
  sharedSecret: 1, signerCert: 1, trustAnchors: 1, intermediates: 1, time: 1,
54
54
  transactionID: 1, expectRecipNonce: 1, revocationChecker: 1, maxIterations: 1, kem: 1,
55
+ messageTimeTolerance: 1,
55
56
  });
56
57
  var KNOWN_KEM_VERIFY_KEYS = intrinsic.assign(intrinsic.create(null),
57
58
  { sharedSecret: 1, transactionID: 1 });
@@ -146,6 +147,30 @@ function _sanGeneralNames(parsed) {
146
147
  return [];
147
148
  }
148
149
 
150
+ /** @internal An anonymous sender: the NULL-DN a requester uses when it knows nothing about its own
151
+ * name (RFC 9810 sec. 5.1.1), encoded as a directoryName with no relative names. */
152
+ function _isNullDnSender(sender) {
153
+ return !!sender && sender.tagClass === "context" && sender.tagNumber === 4 &&
154
+ !!sender.value && _isArray(sender.value.rdns) && sender.value.rdns.length === 0;
155
+ }
156
+
157
+ /** @internal The commonName of a sender given as a directoryName, which is the name a MAC-protected
158
+ * message identifies its shared secret by (RFC 9483 sec. 4.1.5). A sender in any other GeneralName
159
+ * form, or one carrying no commonName, names nothing a senderKID could equal. */
160
+ function _senderCommonName(sender) {
161
+ if (!sender || sender.tagClass !== "context" || sender.tagNumber !== 4) return null;
162
+ var rdns = sender.value && sender.value.rdns;
163
+ if (!_isArray(rdns)) return null;
164
+ var cnOid = oid.byName("commonName");
165
+ for (var i = 0; i < rdns.length; i++) {
166
+ var rdn = rdns[i];
167
+ for (var j = 0; j < rdn.length; j++) {
168
+ if (rdn[j].type === cnOid && typeof rdn[j].value === "string") return rdn[j].value;
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+
149
174
  function _senderBoundToCert(sender, parsed) {
150
175
  if (!sender || !sender.bytes) return false;
151
176
  var subjectRdns = parsed.subject.rdns;
@@ -311,6 +336,15 @@ function _uriEqual(a, b) {
311
336
  return na === nb;
312
337
  }
313
338
 
339
+ /** @internal Whether a declared key identifier names this certificate. Absent means the rule does not
340
+ * apply; present with no subjectKeyIdentifier to compare means it cannot be satisfied, so the message
341
+ * is refused rather than admitted for want of a value. */
342
+ function _senderKidMatchesCert(senderKID, parsed) {
343
+ if (senderKID == null) return true;
344
+ var ski = _certSki(parsed);
345
+ return ski != null && guard.crypto.constantTimeEqual(ski, senderKID);
346
+ }
347
+
314
348
  function _resolveSignerCert(m, opts, extra) {
315
349
  var senderKID = m.header.senderKID;
316
350
  function _signerObj(der, p) { return { der: der, spki: p.subjectPublicKeyInfo.bytes, subject: _subjectDn(p), parsed: p }; }
@@ -318,10 +352,7 @@ function _resolveSignerCert(m, opts, extra) {
318
352
  var p;
319
353
  try { p = x509.parse(der); }
320
354
  catch (_e) { return null; }
321
- if (senderKID != null) {
322
- var ski = _certSki(p);
323
- if (ski == null || !guard.crypto.constantTimeEqual(ski, senderKID)) return null;
324
- }
355
+ if (!_senderKidMatchesCert(senderKID, p)) return null;
325
356
  return _signerObj(der, p);
326
357
  }
327
358
  if (opts.signerCert != null) {
@@ -361,6 +392,22 @@ function _headerChecks(m, opts) {
361
392
  if (opts.expectRecipNonce != null && !_bufEq(m.header.recipNonce, opts.expectRecipNonce)) {
362
393
  return { code: "cmp/bad-recip-nonce", reason: "header.recipNonce does not echo the expected sender nonce" };
363
394
  }
395
+ /** @internal A stated window is checked in both directions: a clock runs fast as readily as slow, and
396
+ * a message dated ahead of the receiver is as much a replay signal as one dated behind it. */
397
+ if (opts.messageTimeTolerance != null && m.header.messageTime != null) {
398
+ var at = guard.time.instantOf(opts.time != null ? opts.time : new _Date());
399
+ var sent = guard.time.instantOf(m.header.messageTime);
400
+ if (!intrinsic.isFinite(at) || !intrinsic.isFinite(sent)) {
401
+ return { code: "cmp/bad-message-time", reason: "the header messageTime could not be read as an instant to compare against the verification time (RFC 9483 sec. 3.5)" };
402
+ }
403
+ // allow:nan-date-comparison-unguarded -- both instants are finite by the check above
404
+ var skew = (at > sent ? at - sent : sent - at) / 1000;
405
+ if (skew > opts.messageTimeTolerance) {
406
+ return { code: "cmp/bad-message-time", reason: "the header messageTime is " + intrinsic.floor(skew) +
407
+ " seconds from the verification time, outside the " + opts.messageTimeTolerance +
408
+ "-second window this caller accepts (RFC 9483 sec. 3.5)" };
409
+ }
410
+ }
364
411
  return null;
365
412
  }
366
413
 
@@ -460,6 +507,23 @@ async function _verifyMac(m, protectedPart, protectionAlg, protection, opts) {
460
507
  if (!guard.crypto.constantTimeEqual(computed, protection.bytes)) {
461
508
  return _fail(m, "mac", protectionAlg, "cmp/protection-failed", "the PBMAC1 MAC does not verify (a wrong shared secret or a tampered ProtectedPart)");
462
509
  }
510
+ /** @internal Under MAC protection the sender names the shared secret, and sec. 4.1.5 requires the
511
+ * senderKID to carry that same name: the identifier and the name the message is attributed to are
512
+ * one value, so a message pointing them at different entities is refused. */
513
+ /** @internal The name rule governs a sender that HAS a name. An anonymous NULL-DN sender carries a
514
+ * reference number instead (RFC 9810 sec. 5.1.1), which names no commonName to equal, so it is
515
+ * required to be present above and left uncompared here rather than refused for failing a match the
516
+ * base protocol does not ask of it. */
517
+ if (m.header.senderKID != null && !_isNullDnSender(m.header.sender)) {
518
+ var cn = _senderCommonName(m.header.sender);
519
+ if (cn === null || !guard.crypto.constantTimeEqual(_bufferFrom(cn, "utf8"), m.header.senderKID)) {
520
+ return _fail(m, "mac", protectionAlg, "cmp/bad-sender-kid",
521
+ "the header senderKID must carry the same name as the commonName of the sender field, which " +
522
+ "is how a MAC-protected message identifies the shared secret it was protected with " +
523
+ "(RFC 9483 sec. 4.1.5)");
524
+ }
525
+ }
526
+
463
527
  var hc = _headerChecks(m, opts);
464
528
  if (hc) return _fail(m, "mac", protectionAlg, hc.code, hc.reason);
465
529
  return _ok(m, "mac", protectionAlg, true, null);
@@ -485,7 +549,17 @@ function _capWork(iterationCount, salt, keyLength, prfHash, opts) {
485
549
  async function _verifySignature(m, protectedPart, protectionAlg, protection, opts) {
486
550
  var extra = _boundExtraCerts(m.extraCerts);
487
551
  var signer = _resolveSignerCert(m, opts, extra);
488
- if (!signer) return _fail(m, "signature", protectionAlg, "cmp/signer-cert-not-found", "no signer certificate resolved (opts.signerCert, senderKID, or extraCerts)");
552
+ if (!signer) {
553
+ /** @internal Selecting from `extraCerts` narrows by the declared key identifier, so "nothing
554
+ * matched" and "nothing was offered" are different failures. Reporting them apart keeps one
555
+ * defect from carrying two codes depending on how the certificate would have arrived. */
556
+ if (m.header.senderKID != null && extra.length) {
557
+ return _fail(m, "signature", protectionAlg, "cmp/bad-sender-kid",
558
+ "no certificate in extraCerts carries the subjectKeyIdentifier the header senderKID declares, " +
559
+ "so nothing offered identifies the key that would verify the protection (RFC 9483 sec. 3.5)");
560
+ }
561
+ return _fail(m, "signature", protectionAlg, "cmp/signer-cert-not-found", "no signer certificate resolved (opts.signerCert, senderKID, or extraCerts)");
562
+ }
489
563
 
490
564
  var ok = await _engine.verifyWithSpki(protectionAlg, protection.bytes, signer.spki, protectedPart);
491
565
  if (ok !== true) return _fail(m, "signature", protectionAlg, "cmp/protection-failed", "the protection signature does not verify over the ProtectedPart under the declared protectionAlg", signer);
@@ -493,6 +567,16 @@ async function _verifySignature(m, protectedPart, protectionAlg, protection, opt
493
567
  if (!_senderBoundToCert(m.header.sender, signer.parsed)) {
494
568
  return _fail(m, "signature", protectionAlg, "cmp/sender-mismatch", "the header sender field does not match the signer certificate subject, or (for an empty subject) a subjectAltName entry (RFC 9483 sec. 3.1, RFC 5280 sec. 7.1)", signer);
495
569
  }
570
+ /** @internal The declared key identifier is checked against the certificate whose key ACTUALLY
571
+ * verified, on every route that certificate arrives by. Selecting from `extraCerts` already matches
572
+ * it while choosing, and a caller-supplied certificate reaches the same rule here, so a message
573
+ * naming one key and signed by another is refused however the verifier came by the certificate
574
+ * (RFC 9483 sec. 3.5). */
575
+ if (!_senderKidMatchesCert(m.header.senderKID, signer.parsed)) {
576
+ return _fail(m, "signature", protectionAlg, "cmp/bad-sender-kid",
577
+ "the header senderKID does not identify the certificate whose key verified the protection: it " +
578
+ "must equal that certificate's subjectKeyIdentifier (RFC 9483 sec. 3.5)", signer);
579
+ }
496
580
 
497
581
  var hc = _headerChecks(m, opts);
498
582
  if (hc) return _fail(m, "signature", protectionAlg, hc.code, hc.reason, signer);
@@ -640,6 +724,18 @@ function _fixSecret(v, made, label) {
640
724
  return v;
641
725
  }
642
726
 
727
+ /** @internal The freshness window, in seconds. RFC 9483 sec. 3.5 leaves the threshold to the use case
728
+ * and lists the check under local policy, so it is stated by the caller rather than defaulted: a
729
+ * window this library invented would refuse conforming messages from a peer whose clock is merely
730
+ * further off than that number. */
731
+ function _fixTolerance(v) {
732
+ if (v == null) return null;
733
+ if (typeof v !== "number" || !intrinsic.isFinite(v) || v < 0) {
734
+ throw _err("cmp/bad-input", "opts.messageTimeTolerance is the accepted messageTime skew in seconds, a non-negative number");
735
+ }
736
+ return v;
737
+ }
738
+
643
739
  /** @internal The KEM verification material, taken once at the door like every other caller value: the
644
740
  * shared secret is copied and registered for wiping, and the transaction identifier is snapshot, so
645
741
  * neither can answer differently when the derivation reads it. */
@@ -675,8 +771,13 @@ function _fixCertList(v, label) {
675
771
  for (var i = 0; i < n; i++) _append(out, _fixByteish(v[i], label + "[" + i + "]"));
676
772
  return out;
677
773
  }
774
+ /** @internal The verification instant, copied so a caller cannot move it after the fact. A value that
775
+ * is not a Date is refused here rather than carried into a comparison that would throw from whichever
776
+ * check happened to read it first. */
678
777
  function _fixTime(v) {
679
- return guard.time.isDate(v) ? new _Date(guard.time.instantOf(v)) : v;
778
+ if (v == null) return v;
779
+ if (!guard.time.isDate(v)) throw _err("cmp/bad-input", "opts.time must be a Date");
780
+ return new _Date(guard.time.instantOf(v));
680
781
  }
681
782
  function _fixVerifyOptions(opts, made) {
682
783
  if (opts == null) opts = {};
@@ -693,6 +794,7 @@ function _fixVerifyOptions(opts, made) {
693
794
  revocationChecker: opts.revocationChecker,
694
795
  maxIterations: opts.maxIterations,
695
796
  kem: _fixKem(opts.kem, made),
797
+ messageTimeTolerance: _fixTolerance(opts.messageTimeTolerance),
696
798
  };
697
799
 
698
800
  _assertEchoBytes(f.transactionID, "transactionID");
@@ -763,6 +865,14 @@ async function _verify(message, opts) {
763
865
  * carry at least 128 bits; a violation is a `{ valid: false }` verdict carrying `cmp/unsupported-version`,
764
866
  * `cmp/bad-transaction-id`, or `cmp/bad-sender-nonce`.
765
867
  *
868
+ * A present `senderKID` must identify the key material that verified the protection (RFC 9483
869
+ * sec. 3.5), whichever way that key was resolved. Under signature protection it must equal the
870
+ * `subjectKeyIdentifier` of the certificate whose key verified, so a certificate supplied as
871
+ * `opts.signerCert` is held to the declared identifier exactly as one selected out of `extraCerts` is,
872
+ * and a certificate carrying no `subjectKeyIdentifier` cannot satisfy one. Under MAC protection it must
873
+ * carry the same name as the `commonName` of the sender field, which is how such a message names the
874
+ * shared secret it was protected with (sec. 4.1.5). A mismatch is a `cmp/bad-sender-kid` verdict.
875
+ *
766
876
  * Returns a verdict (never a bare boolean): `{ valid, trusted, protectionType, protectionAlg, signer,
767
877
  * transactionID, senderNonce, recipNonce, header, body, code?, reason? }`. `valid` is whether the
768
878
  * protection is cryptographically intact under the declared algorithm; `trusted` is whether a MAC secret
@@ -775,6 +885,10 @@ async function _verify(message, opts) {
775
885
  *
776
886
  * @opts
777
887
  * - `sharedSecret` (string|Buffer) -- the PBMAC1 secret; REQUIRED for a MAC-protected message (UTF-8).
888
+ * - `messageTimeTolerance` (number) -- the accepted `messageTime` skew in seconds. RFC 9483
889
+ * sec. 3.5 requires a present `messageTime` to be close to the receiver's current time but leaves
890
+ * the threshold to the use case, so stating it turns the check on; a message outside the window in
891
+ * either direction is a `cmp/bad-message-time` verdict. Absent, the `messageTime` is not judged.
778
892
  * - `kem` ({ sharedSecret, transactionID? }) -- REQUIRED for a KEM-protected message (RFC 9810
779
893
  * sec. 5.1.3.4). `sharedSecret` is what this side encapsulated to the sender's KEM public key, and
780
894
  * `transactionID` names the message that carried the ciphertext when it was not this one. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.6.50",
3
+ "version": "0.6.51",
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:e7a3f132-aaea-4bc5-ae0f-387a72dc5e4f",
5
+ "serialNumber": "urn:uuid:d9015ac6-f8a1-409f-9e1f-5ae1fc7491a1",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-09-07T05:02:17.771Z",
8
+ "timestamp": "2026-09-07T06:27:34.397Z",
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.6.50",
22
+ "bom-ref": "@blamejs/pki@0.6.51",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.6.50",
25
+ "version": "0.6.51",
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.6.50",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.6.51",
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.6.50",
57
+ "ref": "@blamejs/pki@0.6.51",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]