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 opaqueSignedData.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 defaultAuthEnvelopedData(AES-256-GCM) at all — silently, returning an empty plaintext rather than an error. This is why a packaged install shipsCRYPTO_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 |
|
RFC 3156 |
gpg decrypts our PGP/MIME ciphertext |
measured |
|
Only after the SEIPD negotiation below. |
We verify a gpg PGP/MIME signature |
measured |
|
gpg’s own armor and |
We decrypt a gpg PGP/MIME ciphertext |
measured |
|
|
We verify a gpg clearsigned body |
measured |
|
Inline, non-MIME. |
We decrypt a gpg inline ciphertext |
measured |
|
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 |
|---|---|---|---|
|
measured |
|
AES-GCM |
|
measured |
|
Detached and opaque. |
|
measured |
|
|
We open what |
measured |
|
Runs the installed OpenSSL, so it tracks upstream changes. |
We open frozen OpenSSL output |
measured |
|
Eleven checked-in |
We verify frozen OpenSSL signatures |
measured |
|
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 |
Basis |
|---|---|---|
OpenSSL 3.5.6 |
yes |
measured ( |
Thunderbird 140.12.0esr (NSS) |
no |
measured ( |
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:
EnvelopedDatawith AES-256-CBC — theCRYPTO_ALLOW_DOWNGRADE = yesoutput — is read correctly;AuthEnvelopedDatawith 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 —
rmit (or writeCRYPTO_ALLOW_DOWNGRADE = negotiateinpepsi.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, andnegotiateandyesresolve 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
yeschanges 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:
pepsi-cryptoemits the message — our output, not a fixture;openssl pkcs12packages the matching fixture key;Thunderbird starts
--headless --marionette -remote-allow-system-accessagainst a throwaway profile;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;
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:
nsICMSDecoderJSaccumulates 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/signedpart 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
micalgrejects 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
-binaryand 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.