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 following the RFC 8301 §3.2 recommendation). - 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) #
{
message: string | Buffer, // required, non-empty; the bytes signed over.
// Anything else raises mail-crypto/pgp/bad-message
privateKeyPem: string, // required; PEM signing key. Absent or empty raises
// mail-crypto/pgp/bad-key
passphrase: string, // optional; decrypts an encrypted private key. A
// non-string that is not null raises
// mail-crypto/pgp/bad-passphrase
creationTime: number, // default Math.floor(Date.now() / 1000) — the signature's
// creation time in epoch seconds. A non-finite or
// out-of-range value raises mail-crypto/pgp/bad-creation-time
audit: object, // audit handle the mail.crypto.pgp.sign success event is
// emitted through; defaults to the framework's own. Every
// refusal above throws before that emit, so a failed sign
// reaches the caller and not the audit trail
}
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, the size RFC 8301 §3.2 recommends, are refused.
var rv = b.mail.crypto.pgp.sign({
message: "rfc822 body bytes",
privateKeyPem: pem,
});
// → { armored, multipartSigned, signedAt, fingerprint }
b.mail.crypto.pgp.verify(opts) #
{
message: string | Buffer, // required, non-empty; the bytes the signature covers.
// Anything else raises mail-crypto/pgp/bad-message
armored: string, // required; the ASCII-armored detached signature. Absent
// or empty raises mail-crypto/pgp/bad-armor
publicKeyPem: string, // required; PEM verification key. Absent or empty raises
// mail-crypto/pgp/bad-key
audit: object, // audit handle to emit through; defaults to the framework's
// own, so mail.crypto.pgp.verify_fail is recorded either way
}
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. A response whose body exceeds maxKeyBytes refuses with mail-crypto/pgp/wkd-too-large rather than being read to the end.
Before either request it throws mail-crypto/pgp/bad-opts when opts is not an object, mail-crypto/pgp/no-https-get when httpsGet is not a function, and mail-crypto/pgp/bad-max-key-bytes when maxKeyBytes is supplied as anything but a positive integer. Computing the URL throws mail-crypto/pgp/bad-email for an address it cannot split, and mail-crypto/pgp/bad-domain for a domain that is not ASCII letters, digits, hyphens and dots, so an internationalized domain is Punycode-encoded upstream.
// requires: outbound HTTPS to the key owner's Web Key Directory
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
trustAnchorCertsPem: string[], // PEM X.509 roots; omitted or empty verifies the signature and leaves the chain unchecked
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 },
trustAnchorCertsPem: string[], // PEM X.509 roots, passed through to each per-signer verify
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.
mail-crypto/smime/bad-opts refuses the call shape: opts that is not an object, a signerPublicKeys that is not a map, a message that is neither a Buffer nor a string, a signature that is not a Buffer. A SignedData that carries no SignerInfo refuses with mail-crypto/smime/no-signers.
Each signer is verified by the same path b.mail.crypto.smime.verify uses, so every refusal that primitive makes for one signer refuses the envelope here: mail-crypto/smime/parse-failed for a CMS structure it cannot read, mail-crypto/smime/bad-sig-alg and mail-crypto/smime/bad-digest for an algorithm it does not accept, mail-crypto/smime/no-signed-attrs, mail-crypto/smime/no-content-type-attr, mail-crypto/smime/no-message-digest-attr, mail-crypto/smime/content-type-mismatch and mail-crypto/smime/message-digest-mismatch for signed attributes that are absent or disagree with the content, and mail-crypto/smime/signature-mismatch or mail-crypto/smime/verify-failed when the signature does not verify.
With trustAnchorCertsPem the chain walk adds its own, per signer: mail-crypto/smime/no-certs when the envelope carries no certificates, mail-crypto/smime/signer-not-in-chain when none carries the verifying key, mail-crypto/smime/unbindable-sid and mail-crypto/smime/signer-cert-not-named when the SignerInfo cannot be bound to the certificate it names, mail-crypto/smime/cert-expired and mail-crypto/smime/cert-not-yet-valid for a certificate outside its validity window, mail-crypto/smime/chain-pathlen-exceeded and mail-crypto/smime/name-constraint-violation for a chain the constraints refuse, and mail-crypto/smime/untrusted-chain when it reaches no supplied anchor. The inputs to that walk are checked first: mail-crypto/smime/bad-chain-cert for a certificate in the envelope that does not parse, and mail-crypto/smime/bad-trust-anchor for a trustAnchorCertsPem entry that is not a string or does not parse as a certificate.
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: string, // required. One PEM-encoded X.509 certificate; a non-string or empty value throws
}
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.
Each refusal carries its own code. A SHA-1 or MD5 certificate signature throws mail-crypto/smime/refused-hash, and an RSA key under the 2048-bit floor mail-crypto/smime/rsa-too-small. mail-crypto/smime/bad-cert covers a certPem that is not a non-empty string or that does not parse as X.509, and mail-crypto/smime/bad-opts an opts that is not an object. A cert whose validity dates do not parse throws mail-crypto/smime/bad-validity; one outside its validity window, either not yet valid or expired, throws mail-crypto/smime/expired-cert.
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); }
}