Mail PGP
OpenPGP detached-signature signing + verification for mail per RFC 9580 (the November 2024 OpenPGP revision that obsoletes RFC 4880). Produces multipart/signed; protocol= "application/pgp-signature" per RFC 3156 §5 with a v4 OpenPGP signature packet wrapped in ASCII armor (RFC 9580 §6).
Supported v1 surface (sign + verify): - Ed25519 v4 signatures using OpenPGP public-key algorithm 22 (Ed25519Legacy per RFC 9580 §9.1), the universally-supported Ed25519 form. RFC 9580 also defines algorithm 27 (Ed25519) for v6 signatures; v6 signature output is deferred — see the deferral note below. - RSA v4 signatures using OpenPGP public-key algorithm 1 (RSA) with EMSA-PKCS1-v1_5 padding over SHA-256, which is what every fielded PGP implementation expects for v4 RSA signatures. Keys < 2048 bits are refused at sign and verify time: b.mail.dkim.RSA_MIN_BITS, the framework RSA floor, which follows the RFC 8301 §3.2 recommendation and applies across the mail surface.
Threat model: - EFAIL (CVE-2017-17688 / CVE-2017-17689) attacks decrypt-and- render flows that (a) fetch remote content in encrypted parts, (b) tolerate MIME-part-structure mutation between decrypt and render, or (c) feed decrypted HTML to a permissive renderer. This v1 surface is sign + verify only, so EFAIL does not bind directly. When encrypt + decrypt lights up (see deferral note) the renderer-side gate is b.guardHtml strict profile, inline image fetches in encrypted parts are refused, and the MIME-part tree captured at decrypt time is compared byte-for- byte against the tree at render time. - SHA-1 collision attacks (SHAttered, 2017) on signature hash inputs — refuse SHA-1 as the signature hash on verify and never emit it from sign. - Hash-algorithm-confusion: the signature's hash_alg field is enforced against the locally-recomputed hash; verifying with a different algorithm than was signed is refused. - Key-fingerprint pinning: verify() returns the v4 fingerprint (RFC 9580 §5.5.4) of the signing key so the caller can pin to a known operator key rather than trusting any key that happens to match the signature.
Now live (promoted to the stable top-level surface in v0.11.32): - In-process encrypt + decrypt (Message Encrypted Session Key + Symmetrically Encrypted Integrity Protected Data packets, RFC 9580 §5.1 / §5.13) as b.mail.crypto.pgp.encrypt / .decrypt, and WKD key discovery (draft-koch-openpgp-webkey- service) as b.mail.crypto.pgp.wkd — all on the same b.cms substrate that backs S/MIME sign/verify.
Deferred (with the documented condition for opting in): - v6 signature packets (RFC 9580 §5.2.3, packet version 6 with SHA2-512 fingerprints and salted hashes). Defer condition: v6 is not yet emitted by GnuPG 2.4 LTS or by Sequoia stable, so v6 output would fail to verify on the majority of fielded receivers. Reopen when at least two major implementations ship v6 signature verification by default. Cheap escape hatch: operators on v6-only systems can ingest the v4 signature from this module and re-sign with their own v6-capable toolchain.
Surface: var sigBundle = b.mail.crypto.pgp.sign({ message: "rfc822 body bytes", privateKeyPem: "
var rv = b.mail.crypto.pgp.verify({ message: "the signed payload bytes", armored: "-----BEGIN PGP SIGNATURE----- ...", publicKeyPem: "-----BEGIN PUBLIC KEY----- ...", audit: opts.audit, }); // → { ok: true, signerFingerprint: "abcd...", signedAt: epoch, hashAlg: "sha256" }
The signer's message MUST be the canonicalized payload that the verifier will recompute over. For multipart/signed per RFC 3156 §5, the canonical form is the signed part's full MIME headers + body with CRLF line endings — operators producing such a body should pass exactly those bytes here.
RFC citations: - RFC 9580 (OpenPGP, Nov 2024; obsoletes RFC 4880) - RFC 3156 (MIME Security with OpenPGP) - RFC 8301 §3.2 (DKIM RSA key sizes; the framework RSA floor follows its 2048-bit recommendation)
CVE citations: - CVE-2017-17688 / CVE-2017-17689 (EFAIL — informs the encrypt/ decrypt deferral conditions above) - CVE-2019-13050 (PGP keyserver flood — not in scope here; out-of- band fingerprint pinning is the operator's responsibility)
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" });
},
});