Skip to content

fix(smime): send encrypted mail other clients can decrypt - #42

Merged
rathlinus merged 1 commit into
bulwarkmail:mainfrom
b3rsrk:fix/smime-interoperable-content-encryption
Nov 13, 2026
Merged

rathlinus merged 1 commit into
bulwarkmail:mainfrom
b3rsrk:fix/smime-interoperable-content-encryption

Conversation

@b3rsrk

@b3rsrk b3rsrk commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #41.

Encrypted mail currently goes out as EnvelopedData with the content under AES-GCM and the
content key wrapped with RSAES-OAEP. An AEAD cipher belongs in AuthEnvelopedData
(RFC 5083 / RFC 5084), so a receiving client stops at the content-encryption algorithm before it
ever reaches the recipient's key. It then usually reports a missing recipient, which points
everyone at certificates. OpenSSL is blunter: cipher parameter initialisation error.

This switches the wire format to the RFC 8551 baseline, AES-CBC for the content and
RSAES-PKCS1-v1_5 for the key. Both settings values are affected, so there is no configuration
that produces an interoperable message today.

What this gives up, and why

The GCM choice was deliberate: smimeDecrypt returns integrity: true for an AEAD cipher, the
doc comment names EFAIL, and a test is called "counts AES-GCM in EnvelopedData, as this plugin
sends it". So this is a real trade, not a cleanup, and it should be decided rather than merged
quietly.

  • The protection only reaches readers who accept the message, and almost none do. A format no
    recipient can open protects nothing.
  • In S/MIME, integrity for EnvelopedData comes from the signature, and this plugin signs by
    default. Sign-then-encrypt is the standard answer to EFAIL and does not depend on the content
    cipher.
  • The banner already states when decrypted mail carries no integrity protection, and 1.5.0 keeps
    external content off in that case. The mechanism for an unauthenticated EnvelopedData is in
    place.

If authenticated encryption should stay available, AuthEnvelopedData is where RFC 5083 puts it.
The plugin parses that format already but does not build it, so that is a separate change, and an
interoperable default seems worth having before it. Happy to follow up with it if you want it.

Changes

  • smime-encrypt.js: AES-CBC content encryption, key transport without OAEP. Both steps run on
    the liner engine, because native WebCrypto has no RSAES-PKCS1-v1_5. Variant 1 is passed
    explicitly, since pkijs otherwise picks OAEP for an RSA certificate.
  • crypto-engine.js: setCrypto() takes the Crypto object and reads .subtle off it itself.
    Handing it subtle left liner without getRandomValues wherever the browser guard does not
    short-circuit. No effect in the browser; without it the encryption path throws under the test
    runner.
  • smime-decrypt.js is untouched on purpose: AES-GCM in EnvelopedData still reads, and still
    counts as integrity protected, so mail already sent keeps working.
  • encrypt-interop.test.mjs: new. Asserts the OIDs on the wire and has OpenSSL decrypt what the
    plugin produces, as recipient and as sender, with throwaway keypairs.
  • keystore.test.mjs: the one test that encoded the old wire format now asserts the new one.
  • manifest.json: the en and de help text no longer names GCM. The other 25 locales carry
    the same sentence and need their translators.

npx vitest run smime/test/: 12 files, 338 tests, all passing.

EnvelopedData carried an AEAD cipher: the content was encrypted with
AES-GCM and the content key wrapped with RSAES-OAEP. RFC 5083/5084 put an
AEAD cipher in AuthEnvelopedData, so a receiving client stops at the
content-encryption algorithm before it reaches the recipient's key, and
usually reports that as a missing recipient. OpenSSL calls it "cipher
parameter initialisation error".

Encrypt the content with AES-CBC and wrap the key with RSAES-PKCS1-v1_5,
the RFC 8551 baseline. Reading AES-GCM in EnvelopedData stays untouched,
so mail already sent still opens.

CBC carries no integrity of its own, so a decrypted message now reports
integrity: false unless it is signed. That is the standard S/MIME split:
the signature answers for the plaintext, and the banner already says when
nothing does. Authenticated encryption belongs in AuthEnvelopedData,
which this plugin parses but does not build; an interoperable default is
worth having before that.

setCrypto takes the Crypto object and reads .subtle off it itself, so
handing it subtle left liner without getRandomValues wherever the browser
guard does not short-circuit.

Closes bulwarkmail#41
@bulwark-warden

Copy link
Copy Markdown

I found one documentation gap and one test to watch once CI runs.

  1. The README still describes GCM. The Encrypt row says EnvelopedData is built with "AES-256-GCM by default; AES-128 optional" (README.md#L13). This pull request doesn't change that file, so after the change the README would contradict the new help text and CHANGELOG entry.

  2. Encrypted mail to EC certificates. The new loop passes variant 1 for every recipient certificate. smimeEncrypt also gets EC certificates: the test "opens mail with a UKM, which goes into the derived key" encrypts to an EC P-256 certificate and expects recipient.ukm to be defined (keystore.test.mjs#L546-L554). That test will show whether EC recipients still get key agreement. No CI checks have run on the head commit yet. CI runs npm test on ubuntu-24.04 (ci.yml#L16-L32), so its result will confirm or contradict the full-suite run in the description.

One side effect for whoever decides on this trade-off: mail is marked unprotected when !result.integrity && verification.cryptoValid !== true (index.js#L894-L895). Signed and encrypted mail with an intact signature is not affected. Encrypted mail sent without a signature will now get the no-integrity banner, with external content off, in Bulwark. That includes the sender's own copy in Sent.

Bulwark Warden is an AI assistant and can be wrong. It answers from the docs, the code and earlier issues, and links its sources.

@rathlinus

Copy link
Copy Markdown
Member

Thanks, merged. On top I added a test that mail sent with the old GCM format still opens, made the OpenSSL check work on Windows and cover an EC recipient, and made the send stop if pkijs fails to wrap a recipient's key, since it would otherwise leave that key empty without saying so. The help text now says CBC in all languages, and the changelog notes that encrypted mail without a signature now shows the no-integrity note. It will be in the next smime release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: S/MIME encrypts with RSAES-OAEP + AES-GCM inside EnvelopedData, which other clients cannot decrypt

2 participants