.. This file is part of PEPSI. Copyright (C) 2026 Pepsi contributors PEPSI is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. PEPSI is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. .. _interoperability: ================================ 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. 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 :ref:`interop-thunderbird`. * **Outlook, Apple Mail** — not run. See :ref:`interop-untested`. The single most consequential open question in the programme — :ref:`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. .. _interop-openpgp: 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. .. list-table:: :header-rows: 1 :widths: 26 12 34 28 * - 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. .. _interop-seipd: The SEIPDv2 negotiation, confirmed against the real gpg ------------------------------------------------------- ``CRYPTO_ALLOW_DOWNGRADE`` defaults to ``negotiate`` for one concrete reason: .. code-block:: text 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. .. _interop-smime: 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. .. list-table:: :header-rows: 1 :widths: 30 12 34 24 * - 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. .. _interop-aead: 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: .. list-table:: :header-rows: 1 :widths: 34 16 50 * - 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 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. .. _interop-thunderbird: 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-crypto`` emits the message — *our* output, not a fixture; #. ``openssl pkcs12`` packages the matching fixture key; #. Thunderbird starts ``--headless --marionette -remote-allow-system-access`` against 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: * ``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. 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. .. _interop-untested: 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. .. list-table:: :header-rows: 1 :widths: 26 18 18 38 * - Client - Protocol - Status - Notes * - Outlook (desktop) - S/MIME - not run - No version tested. See :ref:`interop-aead`. * - 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 :doc:`secure-link` 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. 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. Reproducing the measured rows ============================= Everything marked *measured* runs from a checkout with no network and no deployment: .. code-block:: sh 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: .. code-block:: sh 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. See also ======== :doc:`key-management` — the identities, custody and discovery layer behind the messages measured here. :doc:`programs/pepsi-stage-encrypt` and :doc:`programs/pepsi-stage-decrypt` — the two stages whose output and input these tables describe. :doc:`rfc-index` — which specification each container and parameter comes from. :doc:`testing` — how the rest of the suite is organised.