@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.
- package/CHANGELOG.md +5 -0
- package/index.js +18 -0
- package/lib/auth/oauth.js +187 -0
- package/lib/auth/saml.js +1366 -13
- package/lib/cms-codec.js +141 -0
- package/lib/compliance.js +73 -0
- package/lib/csp.js +271 -0
- package/lib/dbsc.js +299 -0
- package/lib/fedcm.js +264 -0
- package/lib/hal.js +125 -0
- package/lib/http-client.js +46 -10
- package/lib/importmap-integrity.js +90 -0
- package/lib/jsonapi.js +230 -0
- package/lib/lro.js +200 -0
- package/lib/mail-crypto-pgp.js +312 -2
- package/lib/mail-crypto-smime.js +530 -69
- package/lib/metrics.js +62 -12
- package/lib/middleware/security-headers.js +2 -1
- package/lib/ssrf-guard.js +71 -10
- package/lib/standard-webhooks.js +183 -0
- package/lib/web-push-vapid.js +322 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-crypto-smime.js
CHANGED
|
@@ -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
|
-
*
|
|
51
|
+
* v0.10.16 status — LIVE on `b.cms` substrate:
|
|
52
52
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
|
|
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.
|
|
139
|
-
* @status
|
|
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
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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.
|
|
167
|
-
* @status
|
|
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
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
};
|