28. pepsi-stage-decrypt

Decrypt mail addressed to our users, and say what its signature is worth.

28.1. 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 pepsi-keydisc service releases it. Reference: 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 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.

28.2. 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 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.

28.3. Where it sits

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.

28.4. 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 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.

28.5. Configuration

[stage-<name>]: 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 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.

28.6. 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 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 Key management for the custody model.

28.7. 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 pepsi-stage-check-whitelist and 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.

28.8. See also

pepsi-stage-encrypt (the outbound half), pepsi-stage-arc, pepsi-keys, pepsi-keydisc (the service that settles the parks this stage creates), pepsi-stage-check-whitelist (which gates a whitelist row on the state.signature_verified this stage sets), Key management, Client interoperability, RFC Index, pepsi.conf(5), pepsi.state(7)