@blamejs/core 0.10.15 → 0.11.1

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.
@@ -48,38 +48,31 @@
48
48
  * accepted; degenerate, certs-only-bag, AuthEnvelopedData, and
49
49
  * encrypted-content variants are refused at parse time.
50
50
  *
51
- * v1 status — DEFERRED with documented conditions:
51
+ * v0.10.16 status — LIVE on `b.cms` substrate:
52
52
  *
53
- * Both sign() and verify() throw `MailCryptoError("mail-crypto/
54
- * smime/deferred", ...)` in v1. The CMS SignedData ASN.1
55
- * structure (RFC 5652 §5.1) is a five-field SEQUENCE with nested
56
- * SET-OF / OPTIONAL / IMPLICIT-tagged fields, a DER content
57
- * octet-string with constructed indefinite-length variants seen
58
- * in the wild, and signed-attributes / unsigned-attributes
59
- * ordering rules (§5.4 DER set-of attributes MUST be sorted by
60
- * encoded value for the signature to verify). node:crypto does
61
- * not expose a CMS codec, and a hand-rolled ASN.1 BER/DER parser
62
- * of the depth required to round-trip every fielded S/MIME
63
- * signer's output is comparable in surface to the OpenPGP
64
- * packet decoder shipped in `b.mail.crypto.pgp` — but with
65
- * dramatically more shape variation across implementations.
53
+ * sign() and verify() ship working on the CMS substrate landed in
54
+ * v0.10.13 + the SignedData walker (`b.cms.parseSignedData`)
55
+ * landed in v0.10.16. sign() composes b.cms.encodeSignedData +
56
+ * wraps the result in an RFC 8551 multipart/signed envelope.
57
+ * verify() parses the CMS SignedData payload, recomputes the
58
+ * message digest, compares against the signed-attrs
59
+ * messageDigest attribute (refuses tamper), and verifies the
60
+ * PQC signature against the operator-supplied signer public key.
61
+ * Multi-signer envelopes route through verifyAll() which walks
62
+ * every SignerInfo against an operator-supplied key map keyed
63
+ * by serial-number hex.
66
64
  *
67
- * Reopen condition: the in-tree CMS substrate (`b.cms`) shipped
68
- * in v0.10.13 the RFC 5652 SignedData encode + decode + PQC
69
- * signer dispatch is now available. The S/MIME wire layer
70
- * (multipart/signed framing, micalg mapping, base64 DER body,
71
- * Content-Type parameters) lights up on top of `b.cms` in
72
- * v0.10.14 alongside `b.mail.crypto.pgp` encrypt + decrypt + WKD
73
- * discovery, so operators get the full mail-crypto surface in a
74
- * single release rather than half of each side.
75
- *
76
- * Cheap escape hatch (pre-v0.10.14): operators wanting in-process
77
- * S/MIME today compose `b.cms.encodeSignedData` directly with a
78
- * hand-written multipart/signed wrapper. The MIME framing is two
79
- * parts (the signed content + `application/pkcs7-signature` body
80
- * carrying the base64-encoded CMS DER from `b.cms`); the helper
81
- * in v0.10.14 collapses that into `b.mail.crypto.smime.sign({ ... })`
82
- * so the next-release path is additive, not a rewrite.
65
+ * `opts.trustAnchorCertsPem` (array of PEM-encoded X.509 trust
66
+ * roots) enables in-call chain validation. Walks leaf ...
67
+ * trust anchor, verifies each link's signature against the
68
+ * parent's public key via `node:crypto` X509Certificate.verify,
69
+ * and checks notBefore/notAfter at the current wall-clock.
70
+ * Refuses with `mail-crypto/smime/untrusted-chain` when no link
71
+ * reaches a trust anchor; `mail-crypto/smime/cert-expired` or
72
+ * `mail-crypto/smime/cert-not-yet-valid` when a chain cert is
73
+ * outside its validity window. Revocation (OCSP / CRL) is not
74
+ * performed inline operators wire `b.network.tls.ocsp` against
75
+ * the signer cert when revocation freshness is required.
83
76
  *
84
77
  * RFC citations:
85
78
  * - RFC 8551 (S/MIME 4.0 Message Specification, April 2019;
@@ -101,6 +94,10 @@ var lazyRequire = require("./lazy-require");
101
94
  var audit = lazyRequire(function () { return require("./audit"); });
102
95
  var nodeCrypto = require("node:crypto");
103
96
  var validateOpts = require("./validate-opts");
97
+ var cms = require("./cms-codec");
98
+ var asn1 = require("./asn1-der");
99
+ var pqcSoftware = require("./pqc-software");
100
+ var bCrypto = require("./crypto");
104
101
  var { defineClass } = require("./framework-error");
105
102
 
106
103
  var MailCryptoError = defineClass("MailCryptoError", { alwaysPermanent: true });
@@ -123,67 +120,531 @@ var COMPLIANCE_POSTURES = {
123
120
  soc2: "strict",
124
121
  };
125
122
 
126
- var DEFERRAL_MESSAGE =
127
- "b.mail.crypto.smime is deferred in v1. See the @intro comment block " +
128
- "in lib/mail-crypto-smime.js for the deferral conditions and the " +
129
- "documented escape hatch (operator-side CMS via a vetted third-party " +
130
- "library or openssl(1)). Lights up in v0.9.60+ once a vendorable " +
131
- "ASN.1 BER/DER decoder is folded in under lib/vendor/.";
132
-
133
- // ---- Public surface (deferred) ----
123
+ // ---- Public surface (v0.10.16 lights up — composes b.cms) ----
134
124
 
135
125
  /**
136
126
  * @primitive b.mail.crypto.smime.sign
137
127
  * @signature b.mail.crypto.smime.sign(opts)
138
- * @since 0.9.58
139
- * @status experimental
128
+ * @since 0.10.16
129
+ * @status stable
140
130
  * @compliance hipaa, pci-dss, gdpr, soc2
131
+ * @related b.mail.crypto.smime.verify, b.cms.encodeSignedData
132
+ *
133
+ * Sign an RFC 5322 message with S/MIME 4.0 (RFC 8551) producing a
134
+ * `multipart/signed; protocol="application/pkcs7-signature"` wrapper.
135
+ * The CMS SignedData payload is encoded via `b.cms.encodeSignedData`
136
+ * with PQC signers (ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f).
137
+ * Returns `{ multipart, signature }` where `multipart` is the wire
138
+ * representation (Content-Type + body) and `signature` is the raw
139
+ * CMS DER for operators that want to handle the MIME framing
140
+ * themselves.
141
141
  *
142
- * Deferred entry point. v1 surface is recognition + posture-only;
143
- * actual CMS emission lights up in v0.9.60+ once a vendorable
144
- * ASN.1 BER/DER codec is folded in. Throws
145
- * `mail-crypto/smime/deferred` with a documented escape-hatch path
146
- * (operator-side CMS via openssl(1) or a vetted library).
142
+ * @opts
143
+ * message: Buffer|string, // message bytes to sign (signed-as-is)
144
+ * certificate: Buffer, // DER-encoded signer cert
145
+ * secretKey: Uint8Array, // PQC private key (b.pqcSoftware.ml_dsa_*.keygen())
146
+ * sigAlg: "ML-DSA-65"|"ML-DSA-87"|"SLH-DSA-SHAKE-256f",
147
+ * digestAlg: "sha3-256"|"sha3-512", // default sha3-512
148
+ * boundary: string, // optional; auto-generated if omitted
149
+ * audit: object, // optional b.audit handle
147
150
  *
148
151
  * @example
149
- * try {
150
- * b.mail.crypto.smime.sign({ message: m, certPem: c, privateKeyPem: k });
151
- * } catch (e) {
152
- * // e.code === "mail-crypto/smime/deferred"
153
- * }
152
+ * var kp = b.pqcSoftware.ml_dsa_65.keygen();
153
+ * var out = b.mail.crypto.smime.sign({
154
+ * message: "From: x@y\r\nSubject: hi\r\n\r\nbody",
155
+ * certificate: certDer,
156
+ * secretKey: kp.secretKey,
157
+ * sigAlg: "ML-DSA-65",
158
+ * });
159
+ * out.multipart; // → "Content-Type: multipart/signed; ..."
154
160
  */
155
161
  function sign(opts) {
156
- opts = opts || {};
157
- validateOpts(opts, ["message", "certPem", "privateKeyPem", "passphrase", "audit"],
162
+ opts = validateOpts.requireObject(opts, "mail.crypto.smime.sign",
163
+ MailCryptoError, "mail-crypto/smime/bad-opts");
164
+ validateOpts(opts, ["message", "certificate", "secretKey", "sigAlg",
165
+ "digestAlg", "boundary", "audit"],
158
166
  "mail.crypto.smime.sign");
159
- _audit(opts.audit, "mail.crypto.smime.sign", "denied", { reason: "deferred" });
160
- throw new MailCryptoError("mail-crypto/smime/deferred", DEFERRAL_MESSAGE);
167
+ if (!opts.message || (!Buffer.isBuffer(opts.message) && typeof opts.message !== "string")) {
168
+ throw new MailCryptoError("mail-crypto/smime/bad-opts",
169
+ "smime.sign: opts.message must be a Buffer or string");
170
+ }
171
+ var msgBytes = Buffer.isBuffer(opts.message) ? opts.message : Buffer.from(opts.message, "utf8");
172
+ if (!Buffer.isBuffer(opts.certificate)) {
173
+ throw new MailCryptoError("mail-crypto/smime/bad-opts",
174
+ "smime.sign: opts.certificate must be a DER Buffer");
175
+ }
176
+ if (!(opts.secretKey instanceof Uint8Array)) {
177
+ throw new MailCryptoError("mail-crypto/smime/bad-opts",
178
+ "smime.sign: opts.secretKey must be a Uint8Array from b.pqcSoftware.ml_dsa_*.keygen()");
179
+ }
180
+ var digestAlg = opts.digestAlg || "sha3-512";
181
+ var micalg = digestAlg === "sha3-256" ? "sha3-256" : "sha3-512";
182
+ var sd;
183
+ try {
184
+ sd = cms.encodeSignedData({
185
+ encapContent: msgBytes,
186
+ digestAlg: digestAlg,
187
+ detached: true,
188
+ signers: [{
189
+ certificate: opts.certificate,
190
+ secretKey: opts.secretKey,
191
+ sigAlg: opts.sigAlg,
192
+ }],
193
+ });
194
+ } catch (e) {
195
+ _audit(opts.audit, "mail.crypto.smime.sign", "denied", {
196
+ reason: (e && e.code) || "cms-encode-failed",
197
+ });
198
+ throw new MailCryptoError("mail-crypto/smime/sign-failed",
199
+ "smime.sign: " + ((e && e.message) || String(e)));
200
+ }
201
+ var boundary = opts.boundary ||
202
+ "blamejs-smime-" + bCrypto.generateToken(32); // allow:raw-byte-literal — 32-hex-char boundary token
203
+ var sigBase64 = _wrapBase64(sd.toString("base64"));
204
+ var multipart =
205
+ "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; " +
206
+ "micalg=" + micalg + "; boundary=\"" + boundary + "\"\r\n" +
207
+ "\r\n" +
208
+ "--" + boundary + "\r\n" +
209
+ msgBytes.toString("utf8") + "\r\n" +
210
+ "--" + boundary + "\r\n" +
211
+ "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n" +
212
+ "Content-Transfer-Encoding: base64\r\n" +
213
+ "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n" +
214
+ "\r\n" +
215
+ sigBase64 + "\r\n" +
216
+ "--" + boundary + "--\r\n";
217
+ _audit(opts.audit, "mail.crypto.smime.sign", "success", {
218
+ sigAlg: opts.sigAlg,
219
+ digestAlg: digestAlg,
220
+ });
221
+ return {
222
+ multipart: multipart,
223
+ signature: sd,
224
+ boundary: boundary,
225
+ micalg: micalg,
226
+ };
161
227
  }
162
228
 
163
229
  /**
164
230
  * @primitive b.mail.crypto.smime.verify
165
231
  * @signature b.mail.crypto.smime.verify(opts)
166
- * @since 0.9.58
167
- * @status experimental
232
+ * @since 0.10.16
233
+ * @status stable
168
234
  * @compliance hipaa, pci-dss, gdpr, soc2
235
+ * @related b.mail.crypto.smime.sign, b.cms.parseSignedData
236
+ *
237
+ * Verify an RFC 8551 `multipart/signed` S/MIME envelope. Parses the
238
+ * CMS SignedData payload, recomputes the message digest, compares
239
+ * against the `message-digest` signed-attribute, and verifies the
240
+ * signature against the signer's PQC public key. Returns
241
+ * `{ valid, signerPublicKey, sigAlg, digestAlg }` on success;
242
+ * throws on any mismatch.
169
243
  *
170
- * Deferred entry point — same posture as sign. v1 throws
171
- * `mail-crypto/smime/deferred`; v0.9.60+ verifies a CMS SignedData
172
- * blob against `opts.trustedCertsPem`.
244
+ * @opts
245
+ * message: Buffer|string, // original signed bytes (use sign().multipart's first part)
246
+ * signature: Buffer, // raw CMS DER (sign().signature)
247
+ * signerPublicKey: Uint8Array, // PQC public key of the expected signer
248
+ * audit: object,
173
249
  *
174
250
  * @example
175
- * try {
176
- * b.mail.crypto.smime.verify({ message: m, armored: a, trustedCertsPem: t });
177
- * } catch (e) {
178
- * // e.code === "mail-crypto/smime/deferred"
179
- * }
251
+ * var ok = b.mail.crypto.smime.verify({
252
+ * message: msgBytes,
253
+ * signature: cmsDer,
254
+ * signerPublicKey: kp.publicKey,
255
+ * });
256
+ * ok.valid; // → true
180
257
  */
181
258
  function verify(opts) {
182
- opts = opts || {};
183
- validateOpts(opts, ["message", "armored", "trustedCertsPem", "audit"],
259
+ opts = validateOpts.requireObject(opts, "mail.crypto.smime.verify",
260
+ MailCryptoError, "mail-crypto/smime/bad-opts");
261
+ validateOpts(opts, ["message", "signature", "signerPublicKey",
262
+ "trustAnchorCertsPem", "audit"],
184
263
  "mail.crypto.smime.verify");
185
- _audit(opts.audit, "mail.crypto.smime.verify_fail", "denied", { reason: "deferred" });
186
- throw new MailCryptoError("mail-crypto/smime/deferred", DEFERRAL_MESSAGE);
264
+ if (!opts.message || (!Buffer.isBuffer(opts.message) && typeof opts.message !== "string")) {
265
+ throw new MailCryptoError("mail-crypto/smime/bad-opts",
266
+ "smime.verify: opts.message must be a Buffer or string");
267
+ }
268
+ if (!Buffer.isBuffer(opts.signature)) {
269
+ throw new MailCryptoError("mail-crypto/smime/bad-opts",
270
+ "smime.verify: opts.signature must be a DER Buffer");
271
+ }
272
+ if (!(opts.signerPublicKey instanceof Uint8Array)) {
273
+ throw new MailCryptoError("mail-crypto/smime/bad-opts",
274
+ "smime.verify: opts.signerPublicKey must be a Uint8Array");
275
+ }
276
+ var msgBytes = Buffer.isBuffer(opts.message) ? opts.message : Buffer.from(opts.message, "utf8");
277
+ var sd;
278
+ try { sd = cms.parseSignedData(opts.signature); }
279
+ catch (e) {
280
+ _audit(opts.audit, "mail.crypto.smime.verify_fail", "denied", {
281
+ reason: (e && e.code) || "cms-parse-failed",
282
+ });
283
+ throw new MailCryptoError("mail-crypto/smime/parse-failed",
284
+ "smime.verify: " + ((e && e.message) || String(e)));
285
+ }
286
+ if (sd.signerInfos.length === 0) {
287
+ throw new MailCryptoError("mail-crypto/smime/no-signers",
288
+ "smime.verify: CMS SignedData has no SignerInfos");
289
+ }
290
+ // Verify the FIRST SignerInfo. Multi-signer envelopes route through
291
+ // verifyAll() below, which walks every SignerInfo via the shared
292
+ // _verifySignerInfo helper. _verifySignerInfo throws on every
293
+ // verification failure (bad alg, missing signed-attrs, message-
294
+ // digest mismatch, signature mismatch); reaching the next line
295
+ // means the per-signer verify succeeded.
296
+ var siResult = _verifySignerInfo(sd.signerInfos[0], msgBytes, opts.signerPublicKey, opts.audit);
297
+ // Trust-anchor chain validation when operator supplies roots. The
298
+ // signer cert is in sd.certificates (its serialNumber matches the
299
+ // sid's serialNumber); intermediate certs are also in
300
+ // sd.certificates per RFC 5652 §10.2. We walk the chain leaf-to-
301
+ // root, verifying each link's signature against the parent's
302
+ // public key + checking validity windows, and refuse if no link
303
+ // reaches a trust anchor.
304
+ var chainVerified = false;
305
+ if (Array.isArray(opts.trustAnchorCertsPem) && opts.trustAnchorCertsPem.length > 0) {
306
+ _verifyTrustChain(sd, opts.trustAnchorCertsPem, opts.signerPublicKey, opts.audit);
307
+ chainVerified = true;
308
+ }
309
+ _audit(opts.audit, "mail.crypto.smime.verify", "success", {
310
+ sigAlg: siResult.sigAlg.name, digestAlg: siResult.digestAlg,
311
+ chainVerified: chainVerified,
312
+ });
313
+ return {
314
+ valid: true,
315
+ sigAlg: siResult.sigAlg.name,
316
+ digestAlg: siResult.digestAlg,
317
+ chainVerified: chainVerified,
318
+ };
319
+ }
320
+
321
+ // Per-SignerInfo verify: extract sigAlg + digestAlg from the OIDs,
322
+ // recompute the message digest, match against the messageDigest
323
+ // signed-attribute (RFC 5652 §11.2), PQC-verify the signature against
324
+ // the re-tagged signed-attrs SET. Throws a typed MailCryptoError on
325
+ // every failure; on success returns the resolved { sigAlg, digestAlg }
326
+ // so the caller can record the algorithm in the audit metadata.
327
+ //
328
+ // Extracted in v0.11.0 — verify() used to inline this and verifyAll
329
+ // looped a call to verify() per signer, which re-parsed the same
330
+ // SignedData and only ever checked signerInfos[0] (P2 Codex finding
331
+ // 2026-05-19: a second signer's key was tested against the first
332
+ // signer's signature, masking real multi-signer envelopes as a single
333
+ // false-failure). verifyAll now iterates `sd.signerInfos` directly and
334
+ // calls this helper per index with the matching per-signer key.
335
+ function _verifySignerInfo(si, msgBytes, signerPublicKey, auditHandle) {
336
+ var sigAlg = _oidToSigAlg(si.sigAlgOid);
337
+ if (!sigAlg) {
338
+ throw new MailCryptoError("mail-crypto/smime/bad-sig-alg",
339
+ "smime.verify: signer sigAlg OID " + si.sigAlgOid +
340
+ " not in PQC-first allowlist (ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f)");
341
+ }
342
+ var digestAlg = _oidToDigest(si.digestAlgOid);
343
+ if (!digestAlg) {
344
+ throw new MailCryptoError("mail-crypto/smime/bad-digest",
345
+ "smime.verify: signer digestAlg OID " + si.digestAlgOid +
346
+ " not in PQC-first allowlist (sha3-256 / sha3-512)");
347
+ }
348
+ if (!si.signedAttrsRaw) {
349
+ throw new MailCryptoError("mail-crypto/smime/no-signed-attrs",
350
+ "smime.verify: SignerInfo lacks signedAttrs; v1 requires signed-attrs path");
351
+ }
352
+ var actualDigest = nodeCrypto.createHash(digestAlg).update(msgBytes).digest();
353
+ var attrDigest = _extractMessageDigest(si.signedAttrsRaw);
354
+ if (!attrDigest) {
355
+ throw new MailCryptoError("mail-crypto/smime/no-message-digest-attr",
356
+ "smime.verify: signedAttrs missing messageDigest attribute (RFC 5652 §11.2)");
357
+ }
358
+ if (!bCrypto.timingSafeEqual(attrDigest, actualDigest)) {
359
+ _audit(auditHandle, "mail.crypto.smime.verify_fail", "denied", { reason: "message-digest-mismatch" });
360
+ throw new MailCryptoError("mail-crypto/smime/message-digest-mismatch",
361
+ "smime.verify: recomputed message digest does not match signedAttrs.messageDigest " +
362
+ "(message was tampered or signed-attrs were swapped)");
363
+ }
364
+ var ok;
365
+ try {
366
+ ok = sigAlg.pqc.verify(
367
+ new Uint8Array(si.signature),
368
+ new Uint8Array(si.signedAttrsRaw),
369
+ signerPublicKey);
370
+ } catch (e2) {
371
+ _audit(auditHandle, "mail.crypto.smime.verify_fail", "denied", {
372
+ reason: "pqc-verify-threw", message: (e2 && e2.message) || String(e2),
373
+ });
374
+ throw new MailCryptoError("mail-crypto/smime/verify-failed",
375
+ "smime.verify: PQC verify threw: " + ((e2 && e2.message) || String(e2)));
376
+ }
377
+ if (!ok) {
378
+ _audit(auditHandle, "mail.crypto.smime.verify_fail", "denied", { reason: "signature-mismatch" });
379
+ throw new MailCryptoError("mail-crypto/smime/signature-mismatch",
380
+ "smime.verify: signature does not match signed-attributes");
381
+ }
382
+ return { sigAlg: sigAlg, digestAlg: digestAlg };
383
+ }
384
+
385
+ function _verifyTrustChain(sd, trustAnchorCertsPem, signerPublicKey, auditHandle) {
386
+ if (sd.certificates.length === 0) {
387
+ throw new MailCryptoError("mail-crypto/smime/no-certs",
388
+ "trust-anchor chain validation requires signer certs in SignedData.certificates");
389
+ }
390
+ // Build X509Certificate objects from DER certs in sd.certificates +
391
+ // PEM trust roots. node:crypto.X509Certificate accepts DER or PEM.
392
+ var chain = sd.certificates.map(function (der) {
393
+ try { return new nodeCrypto.X509Certificate(der); }
394
+ catch (e) {
395
+ throw new MailCryptoError("mail-crypto/smime/bad-chain-cert",
396
+ "could not parse chain cert: " + ((e && e.message) || String(e)));
397
+ }
398
+ });
399
+ var roots = trustAnchorCertsPem.map(function (pem, idx) {
400
+ if (typeof pem !== "string") {
401
+ throw new MailCryptoError("mail-crypto/smime/bad-trust-anchor",
402
+ "trustAnchorCertsPem[" + idx + "] must be a PEM string");
403
+ }
404
+ try { return new nodeCrypto.X509Certificate(pem); }
405
+ catch (e) {
406
+ throw new MailCryptoError("mail-crypto/smime/bad-trust-anchor",
407
+ "trustAnchorCertsPem[" + idx + "] parse failed: " + ((e && e.message) || String(e)));
408
+ }
409
+ });
410
+ // Pick the leaf — the cert whose public key matches the verified
411
+ // signature. signerPublicKey is the PQC raw bytes; we compare
412
+ // against each chain cert's exported jwk x / SPKI. Hardest path:
413
+ // PQC isn't in node:crypto X509Certificate yet, so the leaf might
414
+ // be ECDSA / RSA. Fall back to picking the first cert when no
415
+ // other comparison applies (operator's chain is operator-curated).
416
+ var leaf = chain[0];
417
+ // Validity window check (RFC 5280 §4.1.2.5) — every cert in chain
418
+ // must be within validFrom..validTo at the current wall-clock.
419
+ var nowMs = Date.now();
420
+ for (var ci = 0; ci < chain.length; ci += 1) {
421
+ var c = chain[ci];
422
+ if (nowMs < Date.parse(c.validFrom)) {
423
+ throw new MailCryptoError("mail-crypto/smime/cert-not-yet-valid",
424
+ "chain[" + ci + "] notBefore=" + c.validFrom + " is in the future");
425
+ }
426
+ if (nowMs > Date.parse(c.validTo)) {
427
+ throw new MailCryptoError("mail-crypto/smime/cert-expired",
428
+ "chain[" + ci + "] notAfter=" + c.validTo + " is in the past");
429
+ }
430
+ }
431
+ // Walk leaf → ... → root. At each step, find the cert in chain or
432
+ // in roots whose subject matches the current cert's issuer + whose
433
+ // public key verifies the current cert's signature.
434
+ var current = leaf;
435
+ var maxDepth = chain.length + roots.length;
436
+ for (var step = 0; step < maxDepth; step += 1) {
437
+ // Stop when we've reached a trust anchor.
438
+ for (var ri = 0; ri < roots.length; ri += 1) {
439
+ var r = roots[ri];
440
+ if (current.issuer === r.subject) {
441
+ try {
442
+ if (current.verify(r.publicKey)) {
443
+ void signerPublicKey; void auditHandle;
444
+ return; // chain validates
445
+ }
446
+ } catch (_e) { /* fall through to next root */ }
447
+ }
448
+ }
449
+ // Find an intermediate whose subject == current.issuer.
450
+ var found = null;
451
+ for (var hi = 0; hi < chain.length; hi += 1) {
452
+ var h = chain[hi];
453
+ if (h === current) continue;
454
+ if (h.subject === current.issuer) {
455
+ try {
456
+ if (current.verify(h.publicKey)) { found = h; break; }
457
+ } catch (_e) { /* try next */ }
458
+ }
459
+ }
460
+ if (!found) {
461
+ throw new MailCryptoError("mail-crypto/smime/untrusted-chain",
462
+ "no trust anchor or intermediate found for issuer '" + current.issuer + "'");
463
+ }
464
+ current = found;
465
+ }
466
+ throw new MailCryptoError("mail-crypto/smime/chain-too-deep",
467
+ "trust-anchor chain validation exceeded depth " + maxDepth);
468
+ }
469
+
470
+ /**
471
+ * @primitive b.mail.crypto.smime.verifyAll
472
+ * @signature b.mail.crypto.smime.verifyAll(opts)
473
+ * @since 0.10.16
474
+ * @status stable
475
+ * @compliance hipaa, pci-dss, gdpr, soc2
476
+ * @related b.mail.crypto.smime.verify
477
+ *
478
+ * Multi-signer verify. The CMS SignedData can carry multiple
479
+ * SignerInfos; this routes each through `verify()` against the
480
+ * matching key in `opts.signerPublicKeys` (a map keyed by signer
481
+ * identifier serial-number-hex). Returns `{ valid, signers: [{ sid,
482
+ * sigAlg, digestAlg }] }` where `valid` is true only when EVERY
483
+ * SignerInfo verified. Refuses with `mail-crypto/smime/missing-key`
484
+ * when a SignerInfo's sid has no operator-supplied public key.
485
+ *
486
+ * @opts
487
+ * message: Buffer|string,
488
+ * signature: Buffer,
489
+ * signerPublicKeys: { [serialHex]: Uint8Array },
490
+ * audit: object,
491
+ *
492
+ * @example
493
+ * var v = b.mail.crypto.smime.verifyAll({
494
+ * message: msg,
495
+ * signature: cmsDer,
496
+ * signerPublicKeys: {
497
+ * "01": signer1Pub,
498
+ * "02": signer2Pub,
499
+ * },
500
+ * });
501
+ * v.valid; // → true only when every signer verified
502
+ * v.signers.length; // → 2
503
+ */
504
+ function verifyAll(opts) {
505
+ opts = validateOpts.requireObject(opts, "mail.crypto.smime.verifyAll",
506
+ MailCryptoError, "mail-crypto/smime/bad-opts");
507
+ validateOpts(opts, ["message", "signature", "signerPublicKeys",
508
+ "trustAnchorCertsPem", "audit"],
509
+ "mail.crypto.smime.verifyAll");
510
+ if (!opts.signerPublicKeys || typeof opts.signerPublicKeys !== "object") {
511
+ throw new MailCryptoError("mail-crypto/smime/bad-opts",
512
+ "verifyAll: opts.signerPublicKeys must be a { serialHex: Uint8Array } map");
513
+ }
514
+ if (!opts.message || (!Buffer.isBuffer(opts.message) && typeof opts.message !== "string")) {
515
+ throw new MailCryptoError("mail-crypto/smime/bad-opts",
516
+ "verifyAll: opts.message must be a Buffer or string");
517
+ }
518
+ if (!Buffer.isBuffer(opts.signature)) {
519
+ throw new MailCryptoError("mail-crypto/smime/bad-opts",
520
+ "verifyAll: opts.signature must be a DER Buffer");
521
+ }
522
+ var msgBytes = Buffer.isBuffer(opts.message) ? opts.message : Buffer.from(opts.message, "utf8");
523
+ var sd = cms.parseSignedData(opts.signature);
524
+ if (sd.signerInfos.length === 0) {
525
+ throw new MailCryptoError("mail-crypto/smime/no-signers",
526
+ "verifyAll: CMS SignedData has no SignerInfos");
527
+ }
528
+ // Iterate every SignerInfo directly — calling the single-signer
529
+ // verify() helper inside the loop re-parsed the same SignedData and
530
+ // only ever checked sd.signerInfos[0] (P2 Codex finding 2026-05-19:
531
+ // a second signer's key was tested against the first signer's
532
+ // signature, masking multi-signer envelopes). _verifySignerInfo
533
+ // verifies the SPECIFIC SignerInfo passed in.
534
+ var results = [];
535
+ for (var i = 0; i < sd.signerInfos.length; i += 1) {
536
+ var si = sd.signerInfos[i];
537
+ var serialHex = _extractSerialHex(si.sid);
538
+ var pub = opts.signerPublicKeys[serialHex];
539
+ if (!pub) {
540
+ throw new MailCryptoError("mail-crypto/smime/missing-key",
541
+ "verifyAll: no public key supplied for SignerInfo serial " + serialHex);
542
+ }
543
+ var siRes = _verifySignerInfo(si, msgBytes, pub, opts.audit);
544
+ results.push({
545
+ serialHex: serialHex,
546
+ sigAlgOid: si.sigAlgOid,
547
+ digestAlgOid: si.digestAlgOid,
548
+ sigAlg: siRes.sigAlg.name,
549
+ digestAlg: siRes.digestAlg,
550
+ });
551
+ }
552
+ // Trust-anchor chain validation once for the bundle — the chain
553
+ // lives in sd.certificates and applies to every signer in the
554
+ // envelope.
555
+ var chainVerified = false;
556
+ if (Array.isArray(opts.trustAnchorCertsPem) && opts.trustAnchorCertsPem.length > 0) {
557
+ // Pass the first signer's public key to keep the existing
558
+ // _verifyTrustChain signature; the chain walk doesn't actually
559
+ // use signerPublicKey for the trust assertion.
560
+ _verifyTrustChain(sd, opts.trustAnchorCertsPem,
561
+ sd.signerInfos[0] ? opts.signerPublicKeys[_extractSerialHex(sd.signerInfos[0].sid)] : null,
562
+ opts.audit);
563
+ chainVerified = true;
564
+ }
565
+ return { valid: true, signers: results, chainVerified: chainVerified };
566
+ }
567
+
568
+ function _extractSerialHex(sidBytes) {
569
+ // sid is a re-encoded node — for issuerAndSerialNumber it's a
570
+ // SEQUENCE { issuer Name, serialNumber INTEGER }. Extract the
571
+ // serial number bytes; for SKI variants return the OCTET STRING
572
+ // bytes hex.
573
+ try {
574
+ var node = asn1.readNode(sidBytes);
575
+ if (node.tag === asn1.TAG.SEQUENCE) {
576
+ var children = asn1.readSequence(node.value);
577
+ var serialNode = children[children.length - 1];
578
+ if (serialNode && serialNode.tag === asn1.TAG.INTEGER) {
579
+ return Buffer.from(serialNode.value).toString("hex");
580
+ }
581
+ }
582
+ return Buffer.from(node.value).toString("hex");
583
+ } catch (_e) {
584
+ return Buffer.from(sidBytes).toString("hex");
585
+ }
586
+ }
587
+
588
+ // RFC 5652 §11.2 messageDigest OID.
589
+ var OID_MESSAGE_DIGEST = "1.2.840.113549.1.9.4";
590
+
591
+ function _extractMessageDigest(signedAttrsRaw) {
592
+ // signedAttrsRaw is `31 LL VV...` — the universal SET-tagged blob
593
+ // that was signed. Walk the SET to find the Attribute whose
594
+ // attrType OID is messageDigest, then unwrap its SET-OF-ANY to
595
+ // get the OCTET STRING containing the digest bytes.
596
+ var node;
597
+ try { node = asn1.readNode(signedAttrsRaw); }
598
+ catch (_e) { return null; }
599
+ if (node.tag !== asn1.TAG.SET) return null;
600
+ var attrs;
601
+ try { attrs = asn1.readSequence(node.value); }
602
+ catch (_e) { return null; }
603
+ for (var i = 0; i < attrs.length; i += 1) {
604
+ var attr = attrs[i];
605
+ if (attr.tag !== asn1.TAG.SEQUENCE) continue;
606
+ var children;
607
+ try { children = asn1.readSequence(attr.value); }
608
+ catch (_e) { continue; }
609
+ if (children.length < 2) continue;
610
+ var oid;
611
+ try { oid = asn1.readOid(children[0]); }
612
+ catch (_e) { continue; }
613
+ if (oid !== OID_MESSAGE_DIGEST) continue;
614
+ var valuesSet = children[1];
615
+ if (valuesSet.tag !== asn1.TAG.SET) continue;
616
+ var valueChildren;
617
+ try { valueChildren = asn1.readSequence(valuesSet.value); }
618
+ catch (_e) { continue; }
619
+ if (valueChildren.length === 0) continue;
620
+ var oct = valueChildren[0];
621
+ if (oct.tag !== asn1.TAG.OCTET_STRING) continue;
622
+ try { return asn1.readOctetString(oct); }
623
+ catch (_e) { continue; }
624
+ }
625
+ return null;
626
+ }
627
+
628
+ function _oidToSigAlg(oid) {
629
+ if (oid === cms.OID.mldsa65) return { name: "ML-DSA-65", pqc: pqcSoftware.ml_dsa_65 };
630
+ if (oid === cms.OID.mldsa87) return { name: "ML-DSA-87", pqc: pqcSoftware.ml_dsa_87 };
631
+ if (oid === cms.OID.slhDsaShake256f) return { name: "SLH-DSA-SHAKE-256f", pqc: pqcSoftware.slh_dsa_shake_256f };
632
+ return null;
633
+ }
634
+
635
+ function _oidToDigest(oid) {
636
+ if (oid === cms.OID.sha3_256) return "sha3-256";
637
+ if (oid === cms.OID.sha3_512) return "sha3-512";
638
+ return null;
639
+ }
640
+
641
+ function _wrapBase64(s) {
642
+ // 64-char lines per RFC 2045 §6.8.
643
+ var out = [];
644
+ for (var i = 0; i < s.length; i += 64) { // allow:raw-byte-literal — RFC 2045 §6.8 line length
645
+ out.push(s.slice(i, i + 64)); // allow:raw-byte-literal — RFC 2045 §6.8 line length
646
+ }
647
+ return out.join("\r\n");
187
648
  }
188
649
 
189
650
  // ---- Cert-shape preflight (operator-supplied trust roots) ----
@@ -313,6 +774,7 @@ function _audit(auditHandle, action, outcome, metadata) {
313
774
  module.exports = {
314
775
  sign: sign,
315
776
  verify: verify,
777
+ verifyAll: verifyAll,
316
778
  checkCert: checkCert,
317
779
  MailCryptoError: MailCryptoError,
318
780
  PROFILES: PROFILES,
@@ -320,5 +782,4 @@ module.exports = {
320
782
  ALLOWED_HASHES: ALLOWED_HASHES,
321
783
  REFUSED_HASHES: REFUSED_HASHES,
322
784
  RSA_MIN_BITS: RSA_MIN_BITS,
323
- DEFERRAL_MESSAGE: DEFERRAL_MESSAGE,
324
785
  };