.. 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-stage-decrypt =================== *Decrypt mail addressed to our users, and say what its signature is worth.* Role ==== ``pepsi-stage-decrypt`` is the inbound half of Pepsi's end-to-end cryptography. For a message addressed to a recipient this host serves it opens whatever ciphertext the message carries, verifies whatever signature it carries against a real trust chain, records the verdict, harvests the sender's key from the message itself, and removes any security indicator the sender forged. It performs no network I/O: when the signer's key is unknown the message is paused and a :doc:`pepsi-keydisc` service releases it. Reference: :manpage:`pepsi-stage-decrypt(1)`. Both protocols it reads are standards, and the division of labour between them is worth having in view: * **OpenPGP** — RFC 9580 (the crypto refresh, superseding RFC 4880) for the packet grammar and algorithms, RFC 3156 for the PGP/MIME containers (``multipart/encrypted``, ``multipart/signed`` and the ``micalg`` parameter). Inline (non-MIME) OpenPGP is read as well, though never written. * **S/MIME** — RFC 5652 for the Cryptographic Message Syntax, RFC 5083 with RFC 5084 for ``AuthEnvelopedData`` (AES-GCM), RFC 3565 for AES-CBC ``EnvelopedData``, RFC 5753 for ECDH key agreement, and RFC 8551 for the message profile with RFC 8550 for certificate handling. The certificates themselves — path building, the ``emailProtection`` extended key usage and the ``rfc822Name`` binding that ties a certificate to an address — are RFC 5280. Two limits follow from the standards rather than from the implementation, and are stated here so they are not mistaken for bugs. **Certificate revocation is never checked**: neither CRLs (RFC 5280 §5) nor OCSP (RFC 6960) are fetched, so a revoked certificate still validates and the reported revocation status is always "not checked". And an **unauthenticated container is not reported as good**: AES-CBC ``EnvelopedData`` carries no MAC, so it opens with a verdict of *untrusted* rather than *good*, while AES-GCM ``AuthEnvelopedData`` — which is what Pepsi emits — is authenticated. Both are listed in :doc:`../rfc-index`. .. warning:: **Put this stage after the ARC stage and before the content stages.** The recommended inbound chain is ``init = arc → decrypt → aliases → (anti-spam / language / …) → local delivery``. An ARC set seals the message *as it arrived*; decrypting first would make the sealed ``AMS`` describe bytes nobody else ever saw. ``pepsi-setup`` warns when it finds an ARC stage reachable from a decrypt stage, and again when no local-delivery stage is reachable from one. Features ======== * **It only runs for recipients this host serves**, and for anything else it does not even try. Decryption rewrites the body, which invalidates the sender's DKIM signature — harmless for a message about to be filed in a mailbox here, not for one being relayed onward. A message with both kinds of recipient is split, so the forwarded copy is the one that arrived. * **A verdict that distinguishes what matters.** ``valid`` means the signature is sound *and* the key was anchored; ``valid-untrusted`` means sound with a key that could not be tied to anything (a first-seen key, a CA with no anchor here) — which is most of the world's signed mail, and which is **never** reported as ``valid``. ``invalid``, ``unverifiable`` and ``none`` complete the set, and a message with several signatures takes the worst of them. * **Both failure paths deliver by default.** ``ON_DECRYPT_FAILURE = passthrough`` — the user may hold the key in their own client, and bouncing mail we merely could not open helps nobody. ``ON_BAD_SIGNATURE = record`` — mailing lists that rewrite bodies, forwarders and clients with broken canonicalisation all produce bad signatures on legitimate mail. ``quarantine`` and ``bounce`` are available for both. * **Forged indicators are removed, unconditionally.** Every ``X-Pepsi-*`` header goes from every inbound message, and this stage's own subject tags go from the front of the ``Subject``, *before* ours are added. ``X-Pepsi-Crypto`` is a private header and therefore forgeable by definition; the removal is the entire basis on which a client may believe it. (``Pepsi-Origin`` is deliberately not in the namespace and survives.) * **A signal the user actually sees.** ``[decrypted][verified]`` prepended to the ``Subject`` in nesting order — for most users the only signal they will ever see, since nobody reading mail in a webmail client inspects headers. It can be switched off, and the wording is configurable. * **Certificates the message carries.** S/MIME signer certificates and ``application/pgp-keys`` parts are read out of the message and offered to the verifier — before the decision to wait for a key, because most signed mail carries the certificate that signed it. They are used and dropped: *storing* what an inbound message teaches is :doc:`pepsi-stage-autocrypt-learn`, which runs later, after the stages that decide whether the message is spam. Such a certificate can only lower a verdict's standing, never raise it — a signature checked against one is ``valid-untrusted`` and never sets ``state.signature_verified``. * **Two facts recorded for the learn stage.** Decryption commits the plaintext over the bytes that arrived, so ``state.crypto.in`` carries ``encrypted`` (there was ciphertext) and ``outer_gossip`` (the *arriving* header block already had an ``Autocrypt-Gossip:`` field, which anything on the path could have written). Both are preconditions for believing a message's gossip, and neither is answerable once the plaintext is in the row. * **Every non-revoked key is tried, including expired ones**, selected by capability (``purpose`` ``encrypt`` or ``both``) rather than by shape. Old mail must stay readable, which is why a decryption key survives its own expiry. Where it sits ============= .. code-block:: text ingress → arc → decrypt → aliases → check-whitelist → anti-spam → detect-language → local delivery Everything that reads the body wants cleartext, and gets it because decryption is early. Everything that authenticates the message *as it arrived* — ARC — must come first. Placement is a performance matter too. The stage declares ``Load::Full``, so every message that passes through it loads its whole body from the database, even one that turns out to be ordinary mail: whether a message is protected cannot be told from the envelope columns, and ``Load`` is fixed per stage. That is affordable on an inbound path terminating in local delivery, and is not a stage to put in front of bulk relay traffic. Waiting for a key ================= When the signer's key is in neither the message nor the ``peer_key`` cache, the message is *parked*: a discovery request is enqueued and a :doc:`pepsi-keydisc` service releases the message when it settles, found or not. So the *first* message from a new correspondent gets a real verdict rather than ``unverifiable``. A fresh negative-cache entry short-circuits the wait entirely, so only that first message ever waits. Whether a parked message is stored decrypted depends on the shape of the message, and the difference is not cosmetic. An S/MIME signature nested inside an envelope survives decryption as an ordinary MIME layer, so the message is parked with the plaintext committed and decrypted exactly once. An OpenPGP signature lives only inside the packet stream and is gone the moment the plaintext is out, so committing the plaintext would destroy the very evidence the key is being fetched to check; such a message is parked unchanged and decrypted again on resume. Configuration ============= ``[stage-]``: ``PROGRAM = pepsi-stage-decrypt``, ``NEXT_STAGE`` (required) and ``BOUNCE_STAGE``, plus ``ENABLED``, ``LOCAL_DOMAINS`` / ``TARGETS`` / ``RECIPIENT_DELIMITER`` / ``REQUIRE_ACCOUNT``, ``ON_DECRYPT_FAILURE``, ``ON_BAD_SIGNATURE``, ``QUARANTINE_STAGE``, ``SUBJECT_TAGS``, ``TAG_DECRYPTED``, ``TAG_VERIFIED``, ``TAG_BAD_SIGNATURE``, ``STRIP_SUBJECT_TAGS``, ``STRIP_SUBJECT_KEYWORDS``, ``ADD_RESULT_HEADER``, ``MAX_LAYERS``, ``TRUST_ANCHOR_TTL``, ``DISCOVERY`` and ``DISCOVERY_TIMEOUT``. All are documented in :manpage:`pepsi.conf(5)`. Those are **behavioural** options and are per-address overridable through the ``pepsi.settings`` table (keyed, for inbound mail, on an envelope recipient). The **cryptographic** policy lives in ``[pepsi]``'s ``CRYPTO_*`` options and ``[pepsi-crypto]``, is system-administrator-only, and is deliberately not reachable from ``pepsi.settings``. One of those does not apply in this direction: ``CRYPTO_ALLOW_DOWNGRADE`` governs what may be *emitted*. SEIPDv1+MDC and AES-CBC ``EnvelopedData`` are always readable, because refusing them inbound would make this host unable to receive mail from most of the world while protecting nobody. Privilege ========= Installed **setuid** ``pepsi-crypto``, mode ``4750 pepsi-crypto:pepsi``, and therefore standalone rather than folded into the unified ``pepsi`` binary — the same arrangement as :doc:`pepsi-stage-encrypt`, and for the same two reasons: the database role ``pepsi-crypto`` is the only one granted ``crypto_identity.private_wrapped`` and PostgreSQL peer authentication keys off the **effective uid**, and the key-encryption key is a ``0640`` fragment owned by the same account. See :doc:`../key-management` for the custody model. State ===== Writes ``state.crypto.in``: whether the message arrived encrypted, what became of the ciphertext, which identity opened it, the signature verdict with its signer, fingerprint, key source and trust rank, and the ordered layer list. Sets ``state.encrypted = true`` for a message that arrived encrypted and ``state.signature_verified = true`` for the ``valid`` verdict **only** — the two keys :doc:`pepsi-stage-check-whitelist` and :doc:`pepsi-stage-auto-whitelist` read, and which no stage produced until this one. On a policy bounce it writes ``state.bounce`` with a ``5.7.0`` enhanced status. See also ======== :doc:`pepsi-stage-encrypt` (the outbound half), :doc:`pepsi-stage-arc`, :doc:`pepsi-keys`, :doc:`pepsi-keydisc` (the service that settles the parks this stage creates), :doc:`pepsi-stage-check-whitelist` (which gates a whitelist row on the ``state.signature_verified`` this stage sets), :doc:`../key-management`, :doc:`../interoperability`, :doc:`../rfc-index`, :manpage:`pepsi.conf(5)`, :manpage:`pepsi.state(7)`