11. Client interoperability

End-to-end encryption is only worth having if the other end can read it. This chapter records what was actually measured, against which versions, and — just as important — which cells have never been run.

Important

A cell in these tables means one of three things, and they are not interchangeable:

measured

An automated test executed the third-party tool and asserted the outcome. The test name is given, so the claim can be re-run.

observed

A human ran it once and wrote down what happened. No test guards it; it may already be false.

not run

Nobody has tried. It is not a claim of failure, and it is emphatically not a claim of success.

Rows are never filled in by inference. If Pepsi’s S/MIME output is readable by OpenSSL it does not follow that Outlook reads it, and this chapter does not pretend otherwise.

11.1. Summary

  • OpenPGP against gpg 2.4.7 — measured, both directions, PGP/MIME and inline. All six cells pass.

  • S/MIME against OpenSSL 3.5.6 — measured, both directions, including AuthEnvelopedData (AES-GCM), EnvelopedData (AES-CBC), RSA-OAEP, RSA PKCS#1 v1.5, ECDH-KARI, and detached and opaque SignedData.

  • S/MIME against Thunderbird 140.12.0esr — measured, decryption only, and the result is a negative: Thunderbird reads our EnvelopedData (AES-256-CBC) and cannot read our compiled-in default AuthEnvelopedData (AES-256-GCM) at all — silently, returning an empty plaintext rather than an error. This is why a packaged install ships CRYPTO_ALLOW_DOWNGRADE = yes. Signature verification and the OpenPGP half are still not run. See Thunderbird: the gate, and how a GUI client is tested without a GUI.

  • Outlook, Apple Mail — not run. See Outlook and Apple Mail: recorded observations, not gates.

The single most consequential open question in the programme — which clients can read S/MIME AuthEnvelopedData — now has its most important answer, and it is no. That answer has been acted on: CRYPTO_ALLOW_DOWNGRADE = yes now ships as a packaged default in ${DATADIR}/config.d/thunderbird.conf, while the compiled-in default stays negotiate — so an installation is interoperable out of the box and a deployment that wants AEAD back deletes one file. Outlook and Apple Mail remain unmeasured; given this result, assume they need it too until someone checks.

11.2. OpenPGP: gpg

Tested against gpg (GnuPG) 2.4.7, libgcrypt 1.11.0, as packaged on Debian 13. Every cell is executed by cargo test -p pepsi-crypto; the tests skip cleanly (they do not fail) on a machine with no gpg.

Capability

Status

Test

Notes

gpg verifies our PGP/MIME signature

measured

pgp.rs::gpg_verifies_our_detached_signature

RFC 3156 multipart/signed.

gpg decrypts our PGP/MIME ciphertext

measured

interop.rs::gpg_decrypts_what_we_emit

Only after the SEIPD negotiation below.

We verify a gpg PGP/MIME signature

measured

interop.rs::we_verify_a_gpg_signature

gpg’s own armor and micalg.

We decrypt a gpg PGP/MIME ciphertext

measured

interop.rs::we_decrypt_what_gpg_emits

We verify a gpg clearsigned body

measured

interop.rs::we_verify_a_gpg_clearsigned_message

Inline, non-MIME.

We decrypt a gpg inline ciphertext

measured

interop.rs::we_decrypt_a_gpg_inline_message

Inline, non-MIME.

gpg reads our inline output

n/a

Pepsi reads inline OpenPGP but never writes it; everything emitted is PGP/MIME. There is nothing to hand gpg.

11.2.1. The SEIPDv2 negotiation, confirmed against the real gpg

CRYPTO_ALLOW_DOWNGRADE defaults to negotiate for one concrete reason:

INTEROP OBSERVATION: gpg (GnuPG) 2.4.7 CANNOT read our SEIPDv2/AEAD ciphertext

That line is printed by interop.rs::gpg_cannot_read_seipd_v2 on every run. gpg 2.4.x — that is, most of the deployed base — cannot open an RFC 9580 SEIPDv2 message at all. Encrypting AES-GCM unconditionally would therefore produce mail that most OpenPGP users cannot read.

negotiate resolves this from the recipient’s own certificate: an OpenPGP certificate states which SEIPD versions its owner can read, so Pepsi emits SEIPDv2 to a correspondent whose key says they can handle it and SEIPDv1+MDC to one whose key says they cannot — and records which happened on the layer (LayerDetail::downgraded) instead of downgrading silently. That is what gpg_decrypts_what_we_emit proves end to end: encrypting to a SEIPDv1-only certificate produces something the installed gpg opens.

Note the asymmetry deliberately built into the two tests. The positive claim (“gpg opens what we emit after negotiating”) is asserted, because it is a claim about our behaviour. The negative claim (“gpg cannot read SEIPDv2”) is only printed, because it is a claim about someone else’s roadmap: a future gpg that gains SEIPDv2 support must not turn this suite red.

11.3. S/MIME: OpenSSL

Tested against OpenSSL 3.5.6 (7 Apr 2026). Executed by cargo test -p pepsi-crypto; skips cleanly when openssl is absent.

Capability

Status

Test

Notes

openssl cms decrypts our ciphertext

measured

smime.rs::openssl_decrypts_what_we_emit

AES-GCM AuthEnvelopedData with RSA-OAEP and with PKCS#1 v1.5; AES-CBC EnvelopedData; ECDH-KARI.

openssl cms verifies our signature

measured

smime.rs::openssl_verifies_what_we_sign

Detached and opaque.

openssl reads our certs-only message

measured

smime.rs::openssl_reads_our_certs_only_message

We open what openssl emits now

measured

smime.rs::we_open_what_openssl_emits_right_now

Runs the installed OpenSSL, so it tracks upstream changes.

We open frozen OpenSSL output

measured

smime.rs::every_openssl_produced_message_opens

Eleven checked-in smime-kat-openssl-*.der known answers, so the reader is tested on machines with no OpenSSL at all.

We verify frozen OpenSSL signatures

measured

smime.rs::every_openssl_produced_signature_verifies

Detached, opaque, EC and P-384.

11.3.1. The AuthEnvelopedData question

This is the most consequential interoperability fact in the programme. One client has now answered it — Thunderbird, below, and the answer was no, which is why the shipped default changed. The rest of the table is still open.

Pepsi’s compiled-in default is S/MIME AuthEnvelopedData (AES-256-GCM, RFC 5083/5084). Unlike OpenPGP there is nothing to negotiate: an X.509 certificate advertises no content-encryption capability, so there is no signal in the recipient’s key to read. CRYPTO_ALLOW_DOWNGRADE = negotiate therefore behaves like no for S/MIME, and a deployment whose correspondents cannot read AEAD must set CRYPTO_ALLOW_DOWNGRADE = yes globally, dropping every S/MIME message to AES-256-CBC EnvelopedData.

Note

An installed Pepsi already sets that switch. The Thunderbird result below is severe enough — unreadable, and silently so — that yes ships as a packaged default in ${DATADIR}/config.d/thunderbird.conf, which is parsed before pepsi.conf. The code default is untouched: delete that one file and the deployment is back on AEAD immediately, which is the right move for a deployment that knows its correspondents. This is the only place the choice is made — pepsi-setup deliberately does not write the option, because anything it put in pepsi.conf would override the packaged file without saying so.

Whether a given deployment needs that switch depends entirely on which clients its correspondents use — and that is what this table is for:

Client

Reads AuthEnvelopedData?

Basis

OpenSSL 3.5.6 cms

yes

measured (smime.rs::openssl_decrypts_what_we_emit, gcm-oaep and gcm-kari cases)

Thunderbird 140.12.0esr (NSS)

no

measured (thunderbird.rs::thunderbird_reads_our_smime); see below

Outlook (desktop)

unknown

not run

Outlook Web

unknown

not run

Apple Mail / iOS Mail

unknown

not run

11.3.2. Thunderbird cannot read it

Measured against Thunderbird 140.12.0esr, for both RSA key transport and ECDH key agreement, on ciphertext this crate produced:

  • EnvelopedData with AES-256-CBC — the CRYPTO_ALLOW_DOWNGRADE = yes output — is read correctly;

  • AuthEnvelopedData with AES-256-GCM — the default output — is not read at all.

Thunderbird’s S/MIME is NSS, so this is a property of NSS rather than of the user interface, and it is very unlikely to change in an ESR series.

Warning

NSS fails silently. It does not raise an error on an AuthEnvelopedData message; it returns an empty plaintext. A caller that checks only for an exception concludes the message decrypted to nothing. Our own test had to be written to treat “opened, zero bytes” as a failure — see the note on plaintext() in thunderbird.rs — and an operator debugging a user’s “the message is blank” report should suspect this before suspecting the message.

The consequence for deployments is concrete: if your correspondents read mail in Thunderbird, you need CRYPTO_ALLOW_DOWNGRADE = yes. Unlike OpenPGP there is nothing to negotiate — an X.509 certificate advertises no content-encryption capability — so Pepsi cannot detect this per recipient and choose for you.

Because it cannot be detected, and because the failure is silent, that value is what a packaged install ships: ${DATADIR}/config.d/thunderbird.conf sets it, and config.d is parsed before pepsi.conf. The trade is deliberate and reversible in one step:

  • the file is a default, not a decision — rm it (or write CRYPTO_ALLOW_DOWNGRADE = negotiate in pepsi.conf, which is parsed afterwards and wins) and every S/MIME message is authenticated encryption again;

  • it costs nothing on the OpenPGP side. The encrypt stage requests the container auto, and negotiate and yes resolve that identically — SEIPDv2 for a certificate that advertises it, SEIPDv1+MDC only for one that does not. The per-recipient negotiation OpenPGP can do is not given up to fix a problem S/MIME cannot avoid;

  • the only other thing yes changes is that an inbound 3DES S/MIME message becomes decryptable instead of refused. 3DES is never emitted under any setting, and RC2, single DES, unprotected OpenPGP SED and MD2 stay refused under all of them.

When NSS gains AEAD support the gate says so on the run that first succeeds — it prints the AES-256-GCM cell as READ CORRECTLY rather than turning red — and the packaged file can be dropped.

Warning

Outlook and Apple Mail remain unmeasured. An operator whose users exchange S/MIME with them should treat CRYPTO_ALLOW_DOWNGRADE = yes as the safe setting and verify by hand with one real message. Pepsi records the container it emitted on each message’s state.crypto, so what actually went out is auditable after the fact rather than guessed at.

11.4. Thunderbird: the gate, and how a GUI client is tested without a GUI

Plan 13 decision D1 makes Thunderbird a release gate alongside gpg and OpenSSL, on the reasoning that it is the most widely deployed client speaking both protocols and the one most likely to catch a MIME, Content-Transfer-Encoding or micalg problem that gpg and OpenSSL accept happily.

The gate is pepsi-crypto/tests/thunderbird.rs, run by make integrationtests (or directly with PEPSI_INTEROP_THUNDERBIRD=1 cargo test -p pepsi-crypto --test thunderbird -- --nocapture). Version tested: Thunderbird 140.12.0esr.

D1 assumed the gate would need “a real profile and a display”, which is why it was scheduled outside make check. It turns out to need neither. Thunderbird’s S/MIME is NSS, and NSS’s CMS decoder is reachable from chrome-privileged JavaScript as @mozilla.org/nsCMSDecoderJS;1, so the test automates no user interface at all:

  1. pepsi-crypto emits the message — our output, not a fixture;

  2. openssl pkcs12 packages the matching fixture key;

  3. Thunderbird starts --headless --marionette -remote-allow-system-access against a throwaway profile;

  4. a small Marionette client (length-prefixed JSON over TCP, implemented inside the test — it is too small to be worth a dependency) runs a chrome script that imports the key into that profile’s NSS database and calls the decoder;

  5. what comes back is compared with the plaintext that was encrypted.

It still lives in make integrationtests rather than make check, but only because starting Thunderbird costs about fifteen seconds. Without PEPSI_INTEROP_THUNDERBIRD=1, or without thunderbird and openssl on PATH, it prints why it is skipping and returns.

Two traps, both of which produced a wrong answer before they were understood, and both of which anyone extending this test will hit:

  • nsICMSDecoderJS accumulates across calls. Reusing one decoder instance for several messages makes the second report the first message’s plaintext appended to its own — which reads as success. One instance per message.

  • NSS reports an unreadable container as an empty result, not an error. A test that checks only for a thrown exception passes on a message that was never decrypted.

What is still not measured: whether Thunderbird verifies what we sign. nsICMSDecoderJS exposes only decrypt, and reaching NSS’s signature verification from chrome script means going through the message-display pipeline, which does need a profile with a configured account. The OpenPGP half is likewise unmeasured against Thunderbird’s RNP, though gpg — a wholly independent implementation — covers both directions for that protocol.

The frozen-corpus half of the mechanism is built and working: pepsi-crypto/tests/data/interop/ holds specimens plus the verdict each must produce, and interop.rs::the_frozen_third_party_corpus_still_opens walks them. It currently holds three gpg 2.4.7 specimens and no Thunderbird specimen — adding one requires no code change; see the README in that directory.

11.4.1. Why the frozen corpus matters

D1 requires that when the gate fails it says whose side changed. A live test cannot: a red result means either our emitter regressed or the client moved, and the run cannot distinguish them. The two halves together can —

  • the frozen corpus is opened by our reader, and the bytes are in git, so a failure there means we regressed; and

  • the live cells run the current tool, so a failure there means they moved.

When the Thunderbird gate is first run, the version tested must be recorded in this chapter and pinned in the harness, and a specimen of its output frozen into the corpus. Bumping that pin is then a deliberate act with its own result to record — which is the property that keeps the gate from rotting.

11.5. Outlook and Apple Mail: recorded observations, not gates

Decision D1 keeps these out of automation on purpose: they cannot run on hardware that can be kept in CI, and a gate that is not actually checked is worse than an honest observation. They are meant to be exercised by hand at release time, with versions and failures recorded here.

Client

Protocol

Status

Notes

Outlook (desktop)

S/MIME

not run

No version tested. See The AuthEnvelopedData question.

Outlook Web

S/MIME

not run

No version tested.

Apple Mail / iOS Mail

S/MIME

not run

No version tested.

A webmail with no crypto support

not run

This is the case The secure-link fallback portal exists for: the recipient has no key and no client support, and gets a notification mail with a link instead of ciphertext they cannot open.

11.6. Encoding rules that interoperability forced

Several rules in the emitter exist only because a second implementation rejected the obvious thing. They are recorded here because they look arbitrary in the code and are expensive to rediscover:

  • Canonical CRLF before signing. A signature covers the MIME entity exactly as it appears on the wire. Sequoia’s armor writer emits bare LF, so signed bytes pass through a CRLF canonicaliser before hashing; without it gpg computes a different digest and reports a bad signature over correct data.

  • The signed entity is byte-preserved. The bytes handed to the verifier are the bytes between the MIME boundaries, untouched — no re-wrapping, no header re-ordering, no transfer-encoding change. Any “tidy-up” of a multipart/signed part invalidates it.

  • ``micalg`` is advisory. It is written correctly on output and ignored on input: the real digest is inside the signature. Clients get it wrong, and rejecting on a mismatched micalg rejects valid mail.

  • ``-recip`` rather than a trailing positional, in the OpenSSL test harness: OpenSSL’s option parsing stops at the first positional argument, which silently drops -binary and produces confusing failures.

11.7. Reproducing the measured rows

Everything marked measured runs from a checkout with no network and no deployment:

cargo test -p pepsi-crypto                      # every measured cell
cargo test -p pepsi-crypto --test interop -- --nocapture   # OpenPGP, verbose

Both skip rather than fail when gpg or OpenSSL is missing, so a machine without them still runs the rest of the suite. To see which cells actually executed, pass --nocapture: a skipped cell prints its reason.

To refresh the frozen gpg specimens against a newer gpg:

PEPSI_CRYPTO_WRITE_INTEROP_CORPUS=1 cargo test -p pepsi-crypto --test interop

That rewrites the specimens and the recorded producer version; it is a deliberate act whose diff a reviewer sees, never part of an ordinary run.

11.8. See also

Key management — the identities, custody and discovery layer behind the messages measured here. pepsi-stage-encrypt and pepsi-stage-decrypt — the two stages whose output and input these tables describe. RFC Index — which specification each container and parameter comes from. Test Suite — how the rest of the suite is organised.