@blamejs/pki 0.4.14 → 0.5.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/MIGRATING.md +2 -2
  3. package/README.md +142 -137
  4. package/index.js +4 -0
  5. package/lib/acme.js +73 -1
  6. package/lib/asn1-der.js +2 -0
  7. package/lib/attrcert-sign.js +4 -0
  8. package/lib/cbor-det.js +32 -16
  9. package/lib/cmc-build.js +880 -0
  10. package/lib/cmc-verify.js +657 -0
  11. package/lib/cmp-build.js +8 -7
  12. package/lib/cmp-verify.js +11 -1
  13. package/lib/cms-sign.js +170 -8
  14. package/lib/cms-verify.js +80 -14
  15. package/lib/crl-sign.js +22 -0
  16. package/lib/crmf-sign.js +5 -2
  17. package/lib/csr-sign.js +3 -0
  18. package/lib/ct.js +72 -0
  19. package/lib/est.js +828 -32
  20. package/lib/framework-error.js +13 -0
  21. package/lib/guard-bytes.js +37 -1
  22. package/lib/guard-range.js +23 -1
  23. package/lib/http-transport.js +9 -3
  24. package/lib/inspect.js +28 -5
  25. package/lib/jose.js +15 -0
  26. package/lib/lint.js +4 -0
  27. package/lib/merkle.js +5 -5
  28. package/lib/ocsp.js +139 -11
  29. package/lib/oid.js +69 -1
  30. package/lib/path-validate.js +438 -100
  31. package/lib/pkcs12-build.js +12 -0
  32. package/lib/schema-all.js +19 -1
  33. package/lib/schema-attrcert.js +27 -0
  34. package/lib/schema-c509.js +6 -0
  35. package/lib/schema-cmc.js +791 -0
  36. package/lib/schema-cmp.js +25 -0
  37. package/lib/schema-cms.js +17 -1
  38. package/lib/schema-crl.js +23 -1
  39. package/lib/schema-crmf.js +13 -0
  40. package/lib/schema-csr.js +11 -0
  41. package/lib/schema-csrattrs.js +6 -0
  42. package/lib/schema-engine.js +6 -2
  43. package/lib/schema-ocsp.js +41 -0
  44. package/lib/schema-pkcs12.js +16 -0
  45. package/lib/schema-pkcs8.js +8 -0
  46. package/lib/schema-pkix.js +6 -0
  47. package/lib/schema-smime.js +4 -4
  48. package/lib/schema-tsp.js +32 -1
  49. package/lib/schema-x509.js +14 -1
  50. package/lib/shbs.js +12 -4
  51. package/lib/sigstore.js +4 -0
  52. package/lib/smime.js +28 -7
  53. package/lib/tls-cert-compress.js +15 -3
  54. package/lib/trust.js +27 -4
  55. package/lib/tsp-sign.js +41 -6
  56. package/lib/vendor/README.md +19 -19
  57. package/lib/webauthn.js +895 -26
  58. package/lib/x509-sign.js +3 -0
  59. package/package.json +1 -1
  60. package/sbom.cdx.json +6 -6
@@ -47,6 +47,7 @@ var CODE_SHAPE = /^[a-z0-9-]+\/[a-z0-9-]+$/;
47
47
  * site instead of shipping prose into a code-switching consumer.
48
48
  *
49
49
  * @example
50
+ * var bytes = Buffer.from([0x30, 0x80]); // indefinite length -- not valid DER
50
51
  * try { pki.asn1.decode(bytes); }
51
52
  * catch (e) {
52
53
  * if (e instanceof pki.errors.PkiError) console.error(e.code);
@@ -88,6 +89,7 @@ class PkiError extends Error {
88
89
  * withCause: boolean, // default: false -- constructor becomes (code, message, cause)
89
90
  *
90
91
  * @example
92
+ * // throws: my/bad-input -- raising the new error type IS what this shows
91
93
  * var MyError = pki.errors.defineClass("MyError");
92
94
  * throw new MyError("my/bad-input", "explanation");
93
95
  */
@@ -273,6 +275,16 @@ var CsrattrsError = defineClass("CsrattrsError", { withCause: true });
273
275
  // `.cause`.
274
276
  var EstError = defineClass("EstError", { withCause: true });
275
277
 
278
+ // CmcError -- an RFC 5272 / 6402 Certificate Management over CMS message-layer
279
+ // fault: a PKIData / PKIResponse whose structure, body-part identity, control
280
+ // placement or status encoding is malformed, a control this side must act on
281
+ // that cannot be decoded, or an exchange binding (transactionId / recipientNonce)
282
+ // that does not match the request it answers. Distinct from EstError because the
283
+ // CMC layer is the MESSAGE and EST is the TRANSPORT that carried it: an operator
284
+ // needs to know whether the CA's answer was unreadable or the HTTP hop failed.
285
+ // Carries the underlying cms/* or asn1/* leaf fault as `.cause`.
286
+ var CmcError = defineClass("CmcError", { withCause: true });
287
+
276
288
  // TransportError -- a fault from the shared node:https transport (pki.transport):
277
289
  // a non-https / unparseable request URL, a missing trust anchor, a TLS handshake /
278
290
  // server-authentication failure, a negotiated protocol below the floor, a response
@@ -380,6 +392,7 @@ module.exports = {
380
392
  SmimeError: SmimeError,
381
393
  CsrattrsError: CsrattrsError,
382
394
  EstError: EstError,
395
+ CmcError: CmcError,
383
396
  TransportError: TransportError,
384
397
  JoseError: JoseError,
385
398
  AcmeError: AcmeError,
@@ -57,4 +57,40 @@ function source(input, ErrorClass, code, label) {
57
57
  throw new ErrorClass(code, label + ": expected a BufferSource (ArrayBuffer / TypedArray / Buffer)");
58
58
  }
59
59
 
60
- module.exports = { view: view, source: source };
60
+ // snapshot(input, ErrorClass, code, label) -> private Buffer copy | throws ErrorClass
61
+ //
62
+ // The parse-then-verify time-of-check/time-of-use defence. A verification entry
63
+ // point that PARSES its input synchronously and then VERIFIES a signature over
64
+ // the same bytes in a later promise turn is reading the caller's memory twice
65
+ // with an await in between. Every byte range the parse surfaced -- the signed
66
+ // content, the signer set, the values the verdict is built from -- is a VIEW into
67
+ // that memory, so anything that rewrites the buffer in the gap makes the verdict
68
+ // describe bytes other than the ones the signature was checked against
69
+ // (CWE-367 TOCTOU reaching a CWE-347 wrong-verdict). The window is real without
70
+ // an attacker in the process: a caller recycling a pooled read buffer across
71
+ // concurrent verifies hits it by accident.
72
+ //
73
+ // So take one private copy at the boundary and read EVERYTHING from it. `view`
74
+ // re-views and is the right guard where the input is consumed in one synchronous
75
+ // pass; this is its sibling for the boundary that spans an await.
76
+ // @enforced-by behavioral -- a copy has no rename-proof code shape to detect (any
77
+ // `Buffer.from(x)` is one, and most are legitimate). The guard is the RED vector
78
+ // that mutates the caller's buffer between parse and verify and asserts the
79
+ // verdict still describes the bytes that were verified.
80
+ function snapshot(input, ErrorClass, code, label) {
81
+ return Buffer.from(view(input, ErrorClass, code, label));
82
+ }
83
+
84
+ // snapshotSource(input, ErrorClass, code, label) -> private Buffer copy | throws
85
+ // The same parse-then-verify defence as `snapshot`, over the FULL W3C BufferSource
86
+ // (raw ArrayBuffer / DataView / any typed-array view) rather than only the
87
+ // Buffer / Uint8Array contract. A parser that accepts a BufferSource must snapshot
88
+ // the same set: leaving an ArrayBuffer or a DataView aliased reopens the window for
89
+ // exactly the inputs that took the wider path in.
90
+ // @enforced-by behavioral -- a copy has no rename-proof shape to detect; the guard
91
+ // is the RED vector that mutates the caller's backing buffer across the await.
92
+ function snapshotSource(input, ErrorClass, code, label) {
93
+ return Buffer.from(source(input, ErrorClass, code, label));
94
+ }
95
+
96
+ module.exports = { view: view, source: source, snapshot: snapshot, snapshotSource: snapshotSource };
@@ -99,4 +99,26 @@ function uint64(value, E, code, label) {
99
99
  return v;
100
100
  }
101
101
 
102
- module.exports = { int: int, uint31: uint31, positiveInt31: positiveInt31, uint64: uint64 };
102
+ // authoredInteger(value, E, code, label) -> BigInt. The AUTHORING counterpart to
103
+ // int(): a value a CALLER supplies for an ASN.1 INTEGER with no upper bound (a CMC
104
+ // Transaction Identifier, a CRMF certReqId), normalized to the BigInt the builders
105
+ // take. Accepts a bigint, or a Number that is a SAFE integer -- isSafeInteger, not
106
+ // isInteger, because a Number above 2^53 has already lost precision by the time it
107
+ // arrives, so a large identifier MUST come as a bigint rather than be silently
108
+ // rounded to a neighbour. Everything else is a config-time reject.
109
+ //
110
+ // Distinct from int() on purpose: int() BOUNDS a value the wire decoded and narrows
111
+ // it to Number; this one is unbounded and returns BigInt, because the field has no
112
+ // ceiling to check against and the encoder wants the wide type.
113
+ // @enforced-by guard-shape-reinlined
114
+ // @guard-shape Number\.isSafeInteger\(\s*[A-Za-z_$][\w$]*\s*\)\s*\)\s*return BigInt
115
+ function authoredInteger(value, E, code, label) {
116
+ if (typeof value === "bigint") return value;
117
+ if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value);
118
+ throw E(code, label + " must be an integer (a safe-integer number, or a bigint for a large value)");
119
+ }
120
+
121
+ module.exports = {
122
+ int: int, uint31: uint31, positiveInt31: positiveInt31, uint64: uint64,
123
+ authoredInteger: authoredInteger,
124
+ };
@@ -13,11 +13,17 @@
13
13
  * drive -- `pki.est` now, `pki.acme` and `pki.cmp` next. This is the ONLY module in
14
14
  * the toolkit that opens a socket; every protocol layer stays transport-agnostic and
15
15
  * composes it (or an injected substitute) through one contract:
16
- * `transport(request) -> Promise<{ status, headers, body }>`. The response triple is
16
+ * `transport(request) -> Promise<{ status, headers, body, tls }>`. The first three are
17
17
  * exactly what a message layer's classifier consumes, so no protocol semantics leak
18
18
  * into the socket layer -- the transport owns socket lifecycle, the TLS trust policy,
19
19
  * the streaming size cap, and the timeout budget; the caller owns HTTP status,
20
- * content-type, redirect, and authentication decisions.
20
+ * content-type, redirect, and authentication decisions. `tls` reports the negotiated
21
+ * channel -- `{ protocol, cipher, peerCertificate }` -- which is the one fact a caller
22
+ * cannot recover from the response bytes. An INJECTED substitute should return it too:
23
+ * `pki.est.serverkeygen` asserts the negotiated cipher can protect the private key it
24
+ * is about to accept, and a transport that reports no cipher is trusted rather than
25
+ * refused (so a loopback test channel works), which means omitting the field silently
26
+ * skips that assertion.
21
27
  *
22
28
  * `pki.transport.https(defaults?)` binds TLS + budget defaults and returns a
23
29
  * transport. Trust is EXPLICIT and fail-closed: a request is refused unless it
@@ -33,7 +39,7 @@
33
39
  * @card
34
40
  * The shared fail-closed node:https transport (est / acme / cmp): explicit trust
35
41
  * anchors, rejectUnauthorized always on, a TLS floor, a streaming response-size cap,
36
- * and a timeout -- behind one `transport(request) -> {status, headers, body}` seam.
42
+ * and a timeout -- behind one `transport(request) -> {status, headers, body, tls}` seam.
37
43
  */
38
44
 
39
45
  var nodeHttps = require("node:https");
package/lib/inspect.js CHANGED
@@ -607,7 +607,7 @@ function _parse(input) {
607
607
  * @primitive pki.inspect.certificate
608
608
  * @signature pki.inspect.certificate(input) -> string
609
609
  * @since 0.2.4
610
- * @status experimental
610
+ * @status stable
611
611
  * @spec RFC 5280
612
612
  * @related pki.schema.x509.parse
613
613
  *
@@ -618,6 +618,10 @@ function _parse(input) {
618
618
  * hex dump rather than failing the whole report. Pure -- no OpenSSL dependency.
619
619
  *
620
620
  * @example
621
+ * var pair = await pki.key.generate("Ed25519");
622
+ * var der = await pki.x509.sign({ subject: "example.com", subjectPublicKey: await pki.key.export(pair.publicKey),
623
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") },
624
+ * { key: await pki.key.export(pair.privateKey) });
621
625
  * var cert = pki.schema.x509.parse(der);
622
626
  * pki.inspect.certificate(cert).split("\n")[0]; // "Certificate:"
623
627
  */
@@ -753,7 +757,7 @@ function _crlExtension(ext, pad) {
753
757
  * @primitive pki.inspect.crl
754
758
  * @signature pki.inspect.crl(input) -> string
755
759
  * @since 0.3.8
756
- * @status experimental
760
+ * @status stable
757
761
  * @spec RFC 5280
758
762
  * @related pki.schema.crl.parse, pki.inspect.certificate
759
763
  *
@@ -765,6 +769,13 @@ function _crlExtension(ext, pad) {
765
769
  * extension renders as hex rather than failing the report.
766
770
  *
767
771
  * @example
772
+ * var pair = await pki.key.generate("Ed25519");
773
+ * var key = await pki.key.export(pair.privateKey);
774
+ * var caCert = await pki.x509.sign({ subject: "Issuing CA", subjectPublicKey: await pki.key.export(pair.publicKey),
775
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
776
+ * extensions: { basicConstraints: { cA: true }, keyUsage: ["cRLSign"] } }, { key: key });
777
+ * var crlDer = await pki.crl.sign({ thisUpdate: new Date("2026-01-01T00:00:00Z"), crlNumber: 1n, revoked: [] },
778
+ * { cert: caCert, key: key });
768
779
  * pki.inspect.crl(crlDer).split("\n")[0]; // "Certificate Revocation List (CRL):"
769
780
  */
770
781
  function crlReport(input) {
@@ -818,7 +829,7 @@ function _attribute(attr, pad) {
818
829
  * @primitive pki.inspect.csr
819
830
  * @signature pki.inspect.csr(input) -> string
820
831
  * @since 0.3.8
821
- * @status experimental
832
+ * @status stable
822
833
  * @spec RFC 2986
823
834
  * @related pki.schema.csr.parse, pki.inspect.certificate
824
835
  *
@@ -829,6 +840,9 @@ function _attribute(attr, pad) {
829
840
  * input `inspect/bad-input`. Best-effort like `certificate`.
830
841
  *
831
842
  * @example
843
+ * var pair = await pki.key.generate("Ed25519");
844
+ * var csrDer = await pki.csr.sign({ subject: "req.example", subjectPublicKey: await pki.key.export(pair.publicKey) },
845
+ * { key: await pki.key.export(pair.privateKey) });
832
846
  * pki.inspect.csr(csrDer).split("\n")[0]; // "Certificate Request:"
833
847
  */
834
848
  function csrReport(input) {
@@ -927,7 +941,7 @@ function _signerInfo(si, pad) {
927
941
  * @primitive pki.inspect.cms
928
942
  * @signature pki.inspect.cms(input) -> string
929
943
  * @since 0.3.8
930
- * @status experimental
944
+ * @status stable
931
945
  * @spec RFC 5652
932
946
  * @related pki.schema.cms.parse, pki.inspect.certificate
933
947
  *
@@ -939,6 +953,11 @@ function _signerInfo(si, pad) {
939
953
  * `pki.schema.cms.parse` result; a non-CMS throws `inspect/bad-cms`. Best-effort.
940
954
  *
941
955
  * @example
956
+ * var pair = await pki.key.generate("Ed25519");
957
+ * var key = await pki.key.export(pair.privateKey);
958
+ * var cert = await pki.x509.sign({ subject: "Signer", subjectPublicKey: await pki.key.export(pair.publicKey),
959
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") }, { key: key });
960
+ * var cmsDer = await pki.cms.sign(Buffer.from("hello"), { cert: cert, key: key });
942
961
  * pki.inspect.cms(cmsDer).split("\n")[0]; // "CMS ContentInfo:"
943
962
  */
944
963
  // A ContentInfo whose content type pki.schema.cms.parse does not dispatch (id-data,
@@ -1003,7 +1022,7 @@ var _INSPECT_BY_FORMAT = { x509: certificate, crl: crlReport, csr: csrReport, cm
1003
1022
  * @primitive pki.inspect.any
1004
1023
  * @signature pki.inspect.any(input) -> string
1005
1024
  * @since 0.3.8
1006
- * @status experimental
1025
+ * @status stable
1007
1026
  * @spec RFC 5280
1008
1027
  * @related pki.schema.detectFormat, pki.inspect.certificate
1009
1028
  *
@@ -1015,6 +1034,10 @@ var _INSPECT_BY_FORMAT = { x509: certificate, crl: crlReport, csr: csrReport, cm
1015
1034
  * `inspect/bad-input`.
1016
1035
  *
1017
1036
  * @example
1037
+ * var pair = await pki.key.generate("Ed25519");
1038
+ * var der = await pki.x509.sign({ subject: "example.com", subjectPublicKey: await pki.key.export(pair.publicKey),
1039
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") },
1040
+ * { key: await pki.key.export(pair.privateKey) });
1018
1041
  * pki.inspect.any(der); // routes to the right report by detected format
1019
1042
  */
1020
1043
  function any(input) {
package/lib/jose.js CHANGED
@@ -347,6 +347,11 @@ function assertPublicJwk(jwk) {
347
347
  * key: object // a public JWK, required unless the profile embeds jwk
348
348
  *
349
349
  * @example
350
+ * var ec = await pki.key.generate({ name: "ECDSA", namedCurve: "P-256" });
351
+ * var accountJwk = await pki.webcrypto.subtle.exportKey("jwk", ec.publicKey);
352
+ * var jws = await pki.jose.sign({ protected: { alg: "ES256", jwk: accountJwk,
353
+ * nonce: "oFvnlFP1wIhRlYS2jTaXbA", url: "https://ca.example/acme/new-acct" },
354
+ * payload: Buffer.from("{}"), key: ec.privateKey });
350
355
  * var v = await pki.jose.verify(jws, { profile: "acme-outer", key: accountJwk });
351
356
  * v.header.alg; // -> "ES256"
352
357
  */
@@ -405,6 +410,10 @@ async function verify(jws, opts) {
405
410
  * jwk: object // the public JWK, when the header embeds jwk
406
411
  *
407
412
  * @example
413
+ * var ec = await pki.key.generate({ name: "ECDSA", namedCurve: "P-256" });
414
+ * var priv = ec.privateKey;
415
+ * var hdr = { alg: "ES256", jwk: await pki.webcrypto.subtle.exportKey("jwk", ec.publicKey),
416
+ * nonce: "oFvnlFP1wIhRlYS2jTaXbA", url: "https://ca.example/acme/new-acct" };
408
417
  * var jws = await pki.jose.sign({ protected: hdr, payload: Buffer.from("{}"), key: priv });
409
418
  */
410
419
  async function sign(opts) {
@@ -491,6 +500,12 @@ var THUMBPRINT_MEMBERS = {
491
500
  * always yields the same thumbprint (the ACME key-authorization anchor).
492
501
  *
493
502
  * @example
503
+ * // the RFC 7638 sec. 3.1 worked example, so the thumbprint below is the spec's own
504
+ * var accountJwk = { kty: "RSA", e: "AQAB", n: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78L" +
505
+ * "hWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXA" +
506
+ * "rwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajr" +
507
+ * "n1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-" +
508
+ * "kEgU8awapJzKnqDKgw" };
494
509
  * await pki.jose.thumbprint(accountJwk); // -> "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
495
510
  */
496
511
  async function thumbprint(jwk) {
package/lib/lint.js CHANGED
@@ -715,6 +715,10 @@ function _applyThreshold(report, severity) {
715
715
  * @opts severity Suppress findings below this floor (default `"notice"`). `counts` and
716
716
  * `worst` always reflect the complete, unfiltered result.
717
717
  * @example
718
+ * var pair = await pki.key.generate("Ed25519");
719
+ * var pemString = await pki.x509.sign({ subject: "example.com", subjectPublicKey: await pki.key.export(pair.publicKey),
720
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") },
721
+ * { key: await pki.key.export(pair.privateKey) }, { pem: true });
718
722
  * var report = pki.lint.certificate(pemString);
719
723
  * report.worst; // "notice" | "error" | ...
720
724
  * report.findings.map(function (f) { return f.id; });
package/lib/merkle.js CHANGED
@@ -102,7 +102,7 @@ function _ctEq(a, b) {
102
102
  * @primitive pki.merkle.leafHash
103
103
  * @signature pki.merkle.leafHash(entry) -> Buffer
104
104
  * @since 0.1.28
105
- * @status experimental
105
+ * @status stable
106
106
  * @spec RFC 6962, RFC 9162
107
107
  * @related pki.merkle.nodeHash, pki.merkle.verifyInclusion
108
108
  *
@@ -122,7 +122,7 @@ function leafHash(entry) {
122
122
  * @primitive pki.merkle.nodeHash
123
123
  * @signature pki.merkle.nodeHash(left, right) -> Buffer
124
124
  * @since 0.1.28
125
- * @status experimental
125
+ * @status stable
126
126
  * @spec RFC 6962, RFC 9162
127
127
  * @related pki.merkle.leafHash
128
128
  *
@@ -146,7 +146,7 @@ function nodeHash(left, right) {
146
146
  * @primitive pki.merkle.emptyRootHash
147
147
  * @signature pki.merkle.emptyRootHash() -> Buffer
148
148
  * @since 0.1.28
149
- * @status experimental
149
+ * @status stable
150
150
  * @spec RFC 6962, RFC 9162
151
151
  * @related pki.merkle.verifyConsistency
152
152
  *
@@ -164,7 +164,7 @@ function emptyRootHash() {
164
164
  * @primitive pki.merkle.verifyInclusion
165
165
  * @signature pki.merkle.verifyInclusion(opts) -> boolean
166
166
  * @since 0.1.28
167
- * @status experimental
167
+ * @status stable
168
168
  * @spec RFC 6962, RFC 9162
169
169
  * @related pki.merkle.leafHash, pki.merkle.verifyConsistency
170
170
  *
@@ -223,7 +223,7 @@ function verifyInclusion(opts) {
223
223
  * @primitive pki.merkle.verifyConsistency
224
224
  * @signature pki.merkle.verifyConsistency(opts) -> boolean
225
225
  * @since 0.1.28
226
- * @status experimental
226
+ * @status stable
227
227
  * @spec RFC 6962, RFC 9162
228
228
  * @related pki.merkle.verifyInclusion, pki.merkle.emptyRootHash
229
229
  *
package/lib/ocsp.js CHANGED
@@ -15,7 +15,9 @@
15
15
  * ML-DSA / SLH-DSA per the responder key. Verification composes the SAME hardened responder-
16
16
  * authorization + signature + currency gates `pki.path.ocspChecker` runs -- there is no weaker
17
17
  * second verify path. Fail-closed: `verify` returns a `"unknown"` verdict (never a silent accept)
18
- * for any unmet gate; malformed input throws a typed `OcspError`.
18
+ * for any unmet gate, with ONE scoped exception -- a request-nonce mismatch downgrades only a
19
+ * `good` to `"unknown"`, leaving a signed, current, authorized `revoked` reported as `revoked`
20
+ * with `nonceMatched: false` (see `verify`). Malformed input throws a typed `OcspError`.
19
21
  * @spec RFC 6960, RFC 9654, RFC 5019
20
22
  * @card Build, sign, and verify RFC 6960 OCSP requests + responses (a responder + a relying party).
21
23
  */
@@ -103,7 +105,7 @@ function _buildCertID(cert, issuer, hashName) {
103
105
  * @primitive pki.ocsp.buildRequest
104
106
  * @signature pki.ocsp.buildRequest(query, opts?) -> Buffer | string
105
107
  * @since 0.2.22
106
- * @status experimental
108
+ * @status stable
107
109
  * @spec RFC 6960, RFC 9654, RFC 5019
108
110
  * @related pki.ocsp.verify, pki.schema.ocsp.parseRequest
109
111
  *
@@ -121,6 +123,15 @@ function _buildCertID(cert, issuer, hashName) {
121
123
  * profile `"lightweight"` -- one Request, SHA-1 CertID, nonce-only extensions (RFC 5019).
122
124
  * pem emit a PEM `OCSP REQUEST` string instead of DER.
123
125
  * @example
126
+ * var ca = await pki.key.generate("Ed25519");
127
+ * var caKey = await pki.key.export(ca.privateKey);
128
+ * var caDer = await pki.x509.sign({ subject: "Example CA", subjectPublicKey: await pki.key.export(ca.publicKey),
129
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
130
+ * extensions: { basicConstraints: { cA: true }, keyUsage: ["keyCertSign"], subjectKeyIdentifier: true } }, { key: caKey });
131
+ * var leaf = await pki.key.generate("Ed25519");
132
+ * var leafDer = await pki.x509.sign({ subject: "leaf.example", subjectPublicKey: await pki.key.export(leaf.publicKey),
133
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") },
134
+ * { cert: caDer, key: caKey });
124
135
  * var der = await pki.ocsp.buildRequest({ cert: leafDer, issuer: caDer }, { nonce: true });
125
136
  */
126
137
  function buildRequest(query, opts) {
@@ -195,7 +206,7 @@ function _normCertDer(cert, what) {
195
206
  * @primitive pki.ocsp.sign
196
207
  * @signature pki.ocsp.sign(responseData, responder, opts?) -> Promise<Buffer | string>
197
208
  * @since 0.2.22
198
- * @status experimental
209
+ * @status stable
199
210
  * @spec RFC 6960, RFC 9654
200
211
  * @related pki.ocsp.verify, pki.ocsp.buildErrorResponse
201
212
  *
@@ -212,6 +223,18 @@ function _normCertDer(cert, what) {
212
223
  * embedCert `false` to omit certs [0] (a direct-CA response the client already trusts).
213
224
  * pem emit a PEM `OCSP RESPONSE` string instead of DER.
214
225
  * @example
226
+ * var ca = await pki.key.generate("Ed25519");
227
+ * var responderPkcs8 = await pki.key.export(ca.privateKey);
228
+ * // the issuing CA responds directly here, so its own certificate is the responder's
229
+ * var caDer = await pki.x509.sign({ subject: "Example CA", subjectPublicKey: await pki.key.export(ca.publicKey),
230
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
231
+ * extensions: { basicConstraints: { cA: true }, keyUsage: ["keyCertSign"], subjectKeyIdentifier: true } },
232
+ * { key: responderPkcs8 });
233
+ * var responderCertDer = caDer;
234
+ * var leaf = await pki.key.generate("Ed25519");
235
+ * var leafDer = await pki.x509.sign({ subject: "leaf.example", subjectPublicKey: await pki.key.export(leaf.publicKey),
236
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") },
237
+ * { cert: caDer, key: responderPkcs8 });
215
238
  * var resp = await pki.ocsp.sign(
216
239
  * { responderID: "byName", responses: [{ cert: leafDer, issuer: caDer, status: "good" }] },
217
240
  * { cert: responderCertDer, key: responderPkcs8 });
@@ -222,6 +245,43 @@ function sign(responseData, responder, opts) {
222
245
  if (!responder || responder.cert == null || responder.key == null) throw _err("ocsp/bad-input", "a responder must be { cert, key }");
223
246
  var respCertDer = _normCertDer(responder.cert, "the responder certificate");
224
247
  var respCert = _certOf(respCertDer, "the responder certificate");
248
+ // Read with the certificate, not after the awaits below. The ResponderID and the
249
+ // embedded certificate are both fixed from `respCert` here; the signature is made
250
+ // several promise turns later, so a `responder` whose key was replaced in between
251
+ // would produce a response naming one responder and signed by another key.
252
+ //
253
+ // Capturing the REFERENCE only closes half of that: it stops `responder.key = other`,
254
+ // but PKCS#8 arrives as caller-owned BYTES and a composite key as a caller-owned
255
+ // { mldsa, trad } object, either of which can be rewritten in place across the same
256
+ // gap with the same result -- a response carrying this responder's ID and certificate
257
+ // over a signature made by a different key, which no relying party can verify. So the
258
+ // material itself is snapshotted here, not just the binding to it. A CryptoKey is
259
+ // opaque and a PEM string is immutable, so both are already safe by reference.
260
+ var ownedKeyBytes = [];
261
+ var responderKey = _snapshotSignerKey(responder.key, ownedKeyBytes);
262
+ // The copy is cleared on the failing path as well as the succeeding one -- a wrong or
263
+ // malformed key is the case an attacker can force, so a success-only wipe would keep the
264
+ // secret exactly when it matters. The wipe covers the WHOLE window the copy exists, not
265
+ // just the signing call: the copy is taken before the response list, the responder ID, the
266
+ // SingleResponses, the dates, the nonce and the signature scheme are validated, and every
267
+ // one of those can fail. Attaching cleanup to signing alone leaves the secret in the heap
268
+ // on each of those earlier exits, which are the easiest ones for a caller to reach.
269
+ function _wipeOwnedKey() {
270
+ if (ownedKeyBytes.length) guard.secret.zeroizeAll(ownedKeyBytes, OcspError, "ocsp/bad-input", "the responder key copy");
271
+ ownedKeyBytes.length = 0;
272
+ }
273
+ var pending;
274
+ try {
275
+ pending = _signResponse(responseData, responder, respCert, respCertDer, responderKey, opts);
276
+ } catch (e) { _wipeOwnedKey(); throw e; }
277
+ return pending.then(function (out) { _wipeOwnedKey(); return out; },
278
+ function (e) { _wipeOwnedKey(); throw e; });
279
+ }
280
+
281
+ // Everything from the key snapshot onward, so a single cleanup in the caller covers every
282
+ // exit -- sync throw and async rejection alike -- rather than each fallible step needing to
283
+ // remember. `responderKey` is the private copy; it is never the caller's object.
284
+ function _signResponse(responseData, responder, respCert, respCertDer, responderKey, opts) {
225
285
  var responses = responseData.responses || [];
226
286
  if (!responses.length) throw _err("ocsp/bad-input", "a response MUST include at least one SingleResponse (RFC 6960 sec. 4.2.1)");
227
287
 
@@ -234,7 +294,7 @@ function sign(responseData, responder, opts) {
234
294
  if (respExts.length) rdChildren.push(b.explicit(1, b.sequence(respExts)));
235
295
  var responseDataDer = b.sequence(rdChildren);
236
296
  var scheme = signScheme.resolveSignScheme(respCert, { combinedRsaSig: true }, true, _signE);
237
- return signScheme.signOverTbs(scheme, responder.key, responseDataDer, _signE).then(function (sig) {
297
+ return signScheme.signOverTbs(scheme, responderKey, responseDataDer, _signE).then(function (sig) {
238
298
  var basicChildren = [responseDataDer, scheme.sigAlgId, b.bitString(sig, 0)];
239
299
  if (opts.embedCert !== false) basicChildren.push(b.explicit(0, b.sequence([b.raw(respCertDer)])));
240
300
  var responseBytes = b.sequence([b.oid(OID_OCSP_BASIC), b.octetString(b.sequence(basicChildren))]);
@@ -253,6 +313,36 @@ function _asDate(d) {
253
313
  if (isNaN(dt.getTime())) throw _err("ocsp/bad-input", "an invalid date value " + JSON.stringify(d));
254
314
  return dt;
255
315
  }
316
+ // A private copy of whatever signer-key material the caller owns, taken at the entry
317
+ // point because signing happens several promise turns later (see `sign`). Bytes are
318
+ // copied; a composite { mldsa, trad } is rebuilt with each component copied, since
319
+ // rewriting a component is the same attack one level down. Anything else -- a CryptoKey,
320
+ // a PEM string -- is not caller-mutable in a way that changes the key, so it rides as-is
321
+ // rather than being coerced into a shape this function does not understand.
322
+ // `owned` collects every buffer THIS function allocated, so the caller can clear them once
323
+ // signing is done. A copy of a private key is a second copy of a secret, which is precisely
324
+ // what this project's secret-lifetime discipline exists to avoid -- so the copy that closes
325
+ // the aliasing window is wiped rather than left to the garbage collector. Only our own
326
+ // allocations are listed: the caller's key is never written to.
327
+ function _snapshotSignerKey(key, owned) {
328
+ if (Buffer.isBuffer(key) || key instanceof Uint8Array) {
329
+ var copy = guard.bytes.snapshot(key, OcspError, "ocsp/bad-input", "the responder key");
330
+ owned.push(copy);
331
+ return copy;
332
+ }
333
+ // The test is "is this a composite DESCRIPTOR", not "does it currently hold bytes". A
334
+ // composite whose components are both PEM strings carries nothing mutable inside it, but
335
+ // the OBJECT is still the caller's: reassigning `key.mldsa` after the call reaches the
336
+ // deferred signing operation just as rewriting a buffer would, and yields a response whose
337
+ // responder ID and embedded certificate describe one responder over a signature made by
338
+ // another key. So the container is always rebuilt, and each component is snapshotted by
339
+ // its own type -- bytes copied, an immutable PEM string passed through.
340
+ if (key && typeof key === "object" && (key.mldsa != null || key.trad != null)) {
341
+ return Object.assign({}, key, { mldsa: _snapshotSignerKey(key.mldsa, owned), trad: _snapshotSignerKey(key.trad, owned) });
342
+ }
343
+ return key;
344
+ }
345
+
256
346
  function _responderID(rid, respCert) {
257
347
  if (rid == null || rid === "byName") return Promise.resolve(b.explicit(1, b.raw(respCert.subject.bytes))); // byName [1] EXPLICIT Name
258
348
  if (rid === "byKey") return _digest("SHA-1", _keyValue(respCert.subjectPublicKeyInfo.bytes)).then(function (kh) { return b.explicit(2, b.octetString(kh)); });
@@ -261,7 +351,10 @@ function _responderID(rid, respCert) {
261
351
  function _buildSingleResponse(r, opts) {
262
352
  r = r || {};
263
353
  var certIdP;
264
- if (r.certID != null) certIdP = Promise.resolve(b.raw(Buffer.isBuffer(r.certID) ? r.certID : Buffer.from(r.certID)));
354
+ // A pre-encoded CertID is spliced in RAW, so its type is checked rather than coerced:
355
+ // Buffer.from(20) would allocate twenty zero octets and emit a structurally broken
356
+ // CertID inside a response this responder then SIGNS.
357
+ if (r.certID != null) certIdP = Promise.resolve(b.raw(guard.bytes.view(r.certID, OcspError, "ocsp/bad-input", "a response entry's certID")));
265
358
  else if (r.cert != null && r.issuer != null) certIdP = _buildCertID(_certOf(r.cert, "a response certificate"), _certOf(r.issuer, "a response issuer"), r.hashAlgorithm || "sha1");
266
359
  else return Promise.reject(_err("ocsp/bad-input", "each response entry needs { certID } or { cert, issuer }"));
267
360
  return certIdP.then(function (certID) {
@@ -292,7 +385,7 @@ function _certStatusNode(status, opts) {
292
385
  * @primitive pki.ocsp.buildErrorResponse
293
386
  * @signature pki.ocsp.buildErrorResponse(status) -> Buffer | string
294
387
  * @since 0.2.22
295
- * @status experimental
388
+ * @status stable
296
389
  * @spec RFC 6960
297
390
  * @related pki.ocsp.sign
298
391
  *
@@ -314,7 +407,7 @@ function buildErrorResponse(status) {
314
407
  * @primitive pki.ocsp.verify
315
408
  * @signature pki.ocsp.verify(response, opts) -> Promise<{ status, responderAuthorized, signatureValid, thisUpdate, nextUpdate, revocationReason?, nonceMatched?, reason }>
316
409
  * @since 0.2.22
317
- * @status experimental
410
+ * @status stable
318
411
  * @spec RFC 6960, RFC 9654, RFC 5019
319
412
  * @related pki.path.ocspChecker, pki.ocsp.buildRequest
320
413
  *
@@ -324,8 +417,16 @@ function buildErrorResponse(status) {
324
417
  * to the target certificate under the CertID's own hashAlgorithm, checks currency
325
418
  * (`thisUpdate`/`nextUpdate`), and -- when `opts.requestNonce` is supplied -- confirms the response
326
419
  * nonce echoes it. This runs the SAME hardened gates `pki.path.ocspChecker` does. Fail-closed: an
327
- * unauthorized, stale, mismatched, or nonce-mismatched response is a `"unknown"` verdict (never a
328
- * silent accept); a malformed response's parse fault surfaces as the parser's `ocsp/*` / `asn1/*`.
420
+ * unauthorized, stale, or CertID-mismatched response is a `"unknown"` verdict (never a silent
421
+ * accept); a malformed response's parse fault surfaces as the parser's `ocsp/*` / `asn1/*`.
422
+ *
423
+ * The request-nonce check is reported, and downgrades `good` ONLY. Every verdict carries
424
+ * `nonceMatched` (true / false / null when the client sent no nonce). An unmatched nonce turns a
425
+ * `good` into `"unknown"`, because a response that is not an answer to this request cannot be relied
426
+ * on to say the certificate is still fine. It does NOT touch `revoked`: revocation does not go stale
427
+ * the way non-revocation does, so discarding a signed, current, authorized `revoked` because it was
428
+ * replayed would hand a soft-failing caller the very certificate the responder refused. A replayed
429
+ * `revoked` is therefore reported as `revoked` with `nonceMatched: false`.
329
430
  *
330
431
  * @opts
331
432
  * cert the target certificate (parsed, DER, or PEM) -- REQUIRED.
@@ -334,6 +435,19 @@ function buildErrorResponse(status) {
334
435
  * requestNonce the nonce the client sent; when given, the response MUST echo it (constant-time).
335
436
  * historicalMode defer a strictly-future revocation (report good) instead of revoking on skew.
336
437
  * @example
438
+ * var ca = await pki.key.generate("Ed25519");
439
+ * var caKey = await pki.key.export(ca.privateKey);
440
+ * var caDer = await pki.x509.sign({ subject: "Example CA", subjectPublicKey: await pki.key.export(ca.publicKey),
441
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
442
+ * extensions: { basicConstraints: { cA: true }, keyUsage: ["keyCertSign"], subjectKeyIdentifier: true } },
443
+ * { key: caKey });
444
+ * var leaf = await pki.key.generate("Ed25519");
445
+ * var leafDer = await pki.x509.sign({ subject: "leaf.example", subjectPublicKey: await pki.key.export(leaf.publicKey),
446
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") },
447
+ * { cert: caDer, key: caKey });
448
+ * var responseDer = await pki.ocsp.sign(
449
+ * { responderID: "byName", responses: [{ cert: leafDer, issuer: caDer, status: "good" }] },
450
+ * { cert: caDer, key: caKey });
337
451
  * var res = await pki.ocsp.verify(responseDer, { cert: leafDer, issuer: caDer });
338
452
  * res.status; // "good" | "revoked" | "unknown"
339
453
  */
@@ -350,14 +464,28 @@ function verify(response, opts) {
350
464
  time = opts.time == null ? new Date() : _asDate(opts.time);
351
465
  } catch (e) { return Promise.reject(e); }
352
466
  return pathValidate.verifyOcspResponse(parsed, cert, issuerCert, time, { historicalMode: opts.historicalMode === true }).then(function (verdict) {
353
- if (opts.requestNonce == null) return verdict;
467
+ // A client that sent no nonce still gets the field, as null. Leaving it absent would make
468
+ // "not requested" indistinguishable from "the field is not there yet" for a consumer reading
469
+ // res.nonceMatched, and the three-state contract is the whole point: true bound, false not
470
+ // bound, null never asked. The verdict is copied rather than mutated, since it is the lower
471
+ // primitive's object and this layer does not own it.
472
+ if (opts.requestNonce == null) return Object.assign({}, verdict, { nonceMatched: null });
354
473
  // A client that sent a nonce binds it (RFC 9654 / RFC 5019 sec. 4): a missing or mismatched
355
474
  // response nonce fails the verdict closed, even if the status/signature were otherwise good.
356
475
  var respNonce = _responseNonce(parsed);
357
476
  var reqNonce = Buffer.isBuffer(opts.requestNonce) ? opts.requestNonce : (opts.requestNonce instanceof Uint8Array ? Buffer.from(opts.requestNonce) : null);
358
477
  var matched = respNonce != null && reqNonce != null && guard.crypto.constantTimeEqual(respNonce, reqNonce);
359
478
  var out = Object.assign({}, verdict, { nonceMatched: matched });
360
- if (!matched && verdict.status !== "unknown") {
479
+ // The downgrade applies to `good` ONLY. `unknown` is the closed direction for a
480
+ // response claiming the certificate is fine, because an unmatched nonce means
481
+ // this is not an answer to this request and the "fine" may be stale. It is NOT
482
+ // the closed direction for `revoked`: revocation does not expire the way
483
+ // non-revocation does, so discarding a signed, current, authorized revoked
484
+ // verdict because it was replayed would hand a soft-fail caller the certificate
485
+ // the responder just refused -- turning the anti-replay defence into the thing
486
+ // that accepts a revoked certificate. `nonceMatched: false` still reports that
487
+ // this response was not bound to this request.
488
+ if (!matched && verdict.status === "good") {
361
489
  return Object.assign(out, { status: "unknown", reason: "the OCSP response nonce does not echo the request nonce (RFC 9654)" });
362
490
  }
363
491
  return out;