Mail crypto (PGP + S/MIME)
End-to-end mail signing + verification, organized into two sub- namespaces by wire format:
- b.mail.crypto.pgp — OpenPGP per RFC 9580 (November 2024), wrapped in multipart/signed; protocol="application/pgp- signature" per RFC 3156. v1 surface: sign() + verify() with v4 detached signatures over Ed25519 (pub-alg 22) and RSA (pub-alg 1, EMSA-PKCS1-v1_5 + SHA-256, 2048-bit floor per RFC 8301). - b.mail.crypto.smime — S/MIME 4.0 per RFC 8551 with CMS SignedData per RFC 5652. Surface: sign() + verify() + verifyAll() (built on the b.cms substrate — digest recompute + timing-safe compare + PQC signature verify + X.509 chain walk) plus checkCert(), the operator-side preflight that refuses SHA-1 / MD5 / < 2048-bit RSA certs at boot.
Both sub-namespaces share MailCryptoError (FrameworkError subclass via defineClass with alwaysPermanent: true) so operator error handling can catch (e) { if (e instanceof b.mail.crypto.MailCryptoError) ... } once and cover both protocols.
Composition with the rest of the mail surface: - DKIM-Signature (b.mail.dkim) signs at the SMTP-message transport boundary; PGP / S/MIME sign at the user-visible payload boundary. The two are complementary — a message can carry BOTH a DKIM-Signature header (proving the sending domain) AND a PGP / S/MIME signature (proving the human sender's key). Operators wiring both wire DKIM via opts.dkimSigner on the smtp transport and call b.mail.crypto.pgp.sign() over the multipart body before handing it to the transport. - When the EFAIL-class encrypt/decrypt surface lights up (see per-sub-namespace deferral conditions), rendered HTML routes through b.guardHtml strict profile and the MIME-part tree is captured at decrypt time + diffed against the tree at render time.
This top-level module is a thin re-export — the actual surface lives in lib/mail-crypto-pgp.js and lib/mail-crypto-smime.js.
RFC citations: - RFC 9580 (OpenPGP, Nov 2024; obsoletes RFC 4880) - RFC 3156 (MIME Security with OpenPGP) - RFC 8551 (S/MIME 4.0 Message Specification; obsoletes RFC 5751) - RFC 5652 (Cryptographic Message Syntax) - RFC 8550 (S/MIME 4.0 Certificate Handling)
CVE citations: - CVE-2017-17688 / CVE-2017-17689 (EFAIL) - SHAttered (2017 SHA-1 collision) + RFC 8551 §2.5 — SHA-1 signature-hash refusal
b.mail.crypto.pgp.sign(opts) #
{
audit:,
creationTime:,
message:,
passphrase:,
privateKeyPem:,
}
Produces a v4 OpenPGP detached signature over opts.message and returns the ASCII-armored signature plus a ready-to-emit multipart/signed; protocol="application/pgp-signature" body (RFC 3156 §5). Ed25519 (algorithm 22) and RSA-PKCS#1-v1.5 over SHA-256 (algorithm 1) are the v1 signing forms; RSA keys below 2048 bits are refused per RFC 8301 §3.1.
var rv = b.mail.crypto.pgp.sign({
message: "rfc822 body bytes",
privateKeyPem: pem,
});
// → { armored, multipartSigned, signedAt, fingerprint }
b.mail.crypto.pgp.verify(opts) #
{
armored:,
audit:,
message:,
publicKeyPem:,
}
Verifies an ASCII-armored OpenPGP detached signature against opts.message using opts.publicKeyPem. The signature's hash algorithm is enforced against the recomputed digest; SHA-1 is refused. Returns the v4 signer fingerprint (RFC 9580 §5.5.4) so callers can pin to a known operator key rather than trusting any key that happens to verify.
var rv = b.mail.crypto.pgp.verify({
message: bytes,
armored: "-----BEGIN PGP SIGNATURE----- ...",
publicKeyPem: pubPem,
});
// → { ok: true, signerFingerprint, signedAt, hashAlg }
b.mail.crypto.pgp.experimental.wkd.fetch(email, opts) #
{
httpsGet: Function, // (url) → Promise<{ status, body }>; REQUIRED
advancedHost: string, // passed through to computeUrl
maxKeyBytes: number, // default 256 KiB
}
Fetch a WKD key for email per draft-koch-openpgp-webkey-service. Tries the direct URL first; on 404 / network failure falls back to the advanced URL. opts.httpsGet(url) → Promise<{ status, body: Buffer }> is operator-supplied so the framework doesn't couple to a specific HTTP client. Returns { keyBytes, source: "direct" | "advanced", url } or throws mail-crypto/pgp/wkd-not-found when both URLs fail.
var key = await b.mail.crypto.pgp.experimental.wkd.fetch("alice@example.com", {
httpsGet: function (url) {
return b.httpClient.request({ url: url, method: "GET" });
},
});
b.mail.crypto.smime.sign(opts) #
{
message: Buffer|string, // message bytes to sign (signed-as-is)
certificate: Buffer, // DER-encoded signer cert
secretKey: Uint8Array, // PQC private key (b.pqcSoftware.ml_dsa_*.keygen())
sigAlg: "ML-DSA-65"|"ML-DSA-87"|"SLH-DSA-SHAKE-256f",
digestAlg: "sha3-256"|"sha3-512", // default sha3-512
boundary: string, // optional; auto-generated if omitted
audit: object, // optional b.audit handle
}
Sign an RFC 5322 message with S/MIME 4.0 (RFC 8551) producing a multipart/signed; protocol="application/pkcs7-signature" wrapper. The CMS SignedData payload is encoded via b.cms.encodeSignedData with PQC signers (ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f). Returns { multipart, signature } where multipart is the wire representation (Content-Type + body) and signature is the raw CMS DER for operators that want to handle the MIME framing themselves.
var kp = b.pqcSoftware.ml_dsa_65.keygen();
var out = b.mail.crypto.smime.sign({
message: "From: x@y\r\nSubject: hi\r\n\r\nbody",
certificate: certDer,
secretKey: kp.secretKey,
sigAlg: "ML-DSA-65",
});
out.multipart; // → "Content-Type: multipart/signed; ..."
b.mail.crypto.smime.verify(opts) #
{
message: Buffer|string, // original signed bytes (use sign().multipart's first part)
signature: Buffer, // raw CMS DER (sign().signature)
signerPublicKey: Uint8Array, // PQC public key of the expected signer
audit: object,
}
Verify an RFC 8551 multipart/signed S/MIME envelope. Parses the CMS SignedData payload, recomputes the message digest, compares against the message-digest signed-attribute, and verifies the signature against the signer's PQC public key. Returns { valid, signerPublicKey, sigAlg, digestAlg } on success; throws on any mismatch.
var ok = b.mail.crypto.smime.verify({
message: msgBytes,
signature: cmsDer,
signerPublicKey: kp.publicKey,
});
ok.valid; // → true
b.mail.crypto.smime.verifyAll(opts) #
{
message: Buffer|string,
signature: Buffer,
signerPublicKeys: { [serialHex]: Uint8Array },
audit: object,
}
Multi-signer verify. The CMS SignedData can carry multiple SignerInfos; this routes each through verify() against the matching key in opts.signerPublicKeys (a map keyed by signer identifier serial-number-hex). Returns { valid, signers: [{ sid, sigAlg, digestAlg }] } where valid is true only when EVERY SignerInfo verified. Refuses with mail-crypto/smime/missing-key when a SignerInfo's sid has no operator-supplied public key.
var v = b.mail.crypto.smime.verifyAll({
message: msg,
signature: cmsDer,
signerPublicKeys: {
"01": signer1Pub,
"02": signer2Pub,
},
});
v.valid; // → true only when every signer verified
v.signers.length; // → 2
b.mail.crypto.smime.checkCert(opts) #
{
certPem:,
}
Operator-side cert preflight that lights up at boot: refuses SHA-1 / MD5 signatures, RSA keys < 2048 bits, MD2 / MD5 / SHA-1 as the certificate-signature algorithm. Returns the parsed cert shape: the full subject / issuer DN strings, the validity window, the signature algorithm (name + OID), the key type, and the SHA-256 fingerprint. Throws mail-crypto/smime/bad-cert on any of the above; throws mail-crypto/smime/expired-cert if the cert is outside its validity window.
var info = b.mail.crypto.smime.checkCert({ certPem: pem });
// → { subject, issuer, validFrom, validTo, sigAlgName, sigAlgOid, keyType, fingerprint256 }
b.mail.crypto.isMailCryptoError(err) #
Duck-type check that returns true for any MailCryptoError raised by either sub-namespace. Each sub-module defines its own MailCryptoError class so instanceof doesn't span them; this helper checks the isMailCryptoError === true flag both classes set, giving operators one cross-protocol catch-all.
try {
b.mail.crypto.pgp.verify(opts);
} catch (e) {
if (b.mail.crypto.isMailCryptoError(e)) { handle(e); }
}
Last updated 2026-08-08T16:39:49.652Z by seeder.