70.1.7. pepsi-stage-decrypt¶
decrypt mail addressed to our users and verify what signed it
- Manual section:
1
70.1.7.1.1. Name¶
pepsi-stage-decrypt - inbound end-to-end decryption and signature verification stage of the Pepsi pipeline.
70.1.7.1.2. Synopsis¶
pepsi-stage-decrypt [GLOBAL-OPTIONS] worker
70.1.7.1.3. Description¶
pepsi-stage-decrypt is a stage program run by pepsi-dispatch(1) as a persistent worker reading message ids on standard input. 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, and removes any security indicator the sender forged.
Server-side decryption is what makes end-to-end mail cryptography usable without client plugins: the user reads ordinary mail in their usual client and the gateway did the work. That trade is only sound because the message is about to stop being an SMTP object and become a file in a mailbox — see Locality.
The verification half is deliberately strict about one distinction. A signature that is cryptographically sound tells you the message was signed by some key; whether that key is the sender’s is a separate question with a separate answer. The two are never reported as the same thing (Verdicts).
70.1.7.1.4. Locality¶
The stage runs only for recipients this host serves, and for a message it does not serve it does not even try — a failed decryption attempt should not colour the verdict, and there is no key for such a message anyway.
The reason is that decryption rewrites the body. The sender’s DKIM signature then no longer verifies, and an ARC seal made before decryption (see Placement) covers the encrypted form. For a message about to be delivered locally that is harmless. For a message being forwarded onward it is not: it would arrive at the next hop with a broken signature, which is a stronger negative signal than no signature at all.
Locality is the same test the delivery stages use — LOCAL_DOMAINS,
TARGETS and RECIPIENT_DELIMITER — with one difference. By default only
the domain has to match, because this stage does not have to know whose mailbox
to write; a hosted domain whose users hold crypto identities but no shell
accounts is an ordinary deployment. Set REQUIRE_ACCOUNT = yes to demand a
passwd entry in TARGETS as well.
A message with both local and foreign recipients is split: the foreign
recipients are peeled onto a sibling row at NEXT_STAGE carrying the message
exactly as it arrived, and this row keeps the local ones. The per-recipient DSN
state is sliced in lockstep by the same code every other recipient split uses.
70.1.7.1.5. Placement¶
The recommended inbound chain is:
init = arc -> decrypt -> aliases -> (anti-spam / language / …) -> local delivery
ARC runs first, and this is not a preference. An ARC set seals the message
as it arrived; decrypting first would make the sealed AMS describe bytes
nobody else ever saw, so the seal would be a claim about a message that never
existed. pepsi-setup(1) warns when it finds an ARC stage reachable from a
decrypt stage.
Content inspection runs after. Language detection, the pay-to-send gate and
the From:-header whitelist check all read the body, and all of them want
cleartext.
Placement is also a performance matter. The stage declares Load::Full,
because whether a message is protected cannot be told from the envelope columns
and Load is fixed per stage. Every message that passes through it therefore
loads its whole body from the database, even one that turns out to be ordinary
unencrypted mail. That is affordable on an inbound path terminating in local
delivery; it is not a stage to put in front of bulk relay traffic.
70.1.7.1.6. Verdicts¶
state.crypto.in.signature.status is one of:
noneThe message carries no signature.
validCryptographically sound and the key was anchored: an X.509 chain to a configured
ca_trustanchor, or an OpenPGP key from a ranked discovery source (DANE, WKD, a key server, or one the operator pinned).valid-untrustedCryptographically sound, but the key could not be tied to anything: a key seen for the first time, learnt from the message itself or from an
Autocryptheader, or a certificate from a CA this deployment holds no anchor for. Most of the world’s signed mail is in this state.This is never reported as ``valid``. It is the distinction the whole stage exists to make, and a security indicator that conflates the two is worse than none.
invalidThe signature does not verify over the bytes it covers, or the signer’s certificate is expired, revoked, or bound to an address other than the message’s
From:.unverifiableNo key or certificate was available for the claimed signer, so nothing could be established either way. This is the verdict that makes the stage wait for a key (Key discovery).
A message carrying several signature layers takes the worst of their
verdicts. One broken signature makes the message invalid however good the
others are.
Only valid sets state.signature_verified, which
pepsi-stage-check-whitelist(1) gates a whitelist entry on.
70.1.7.1.6.1. A signature that covers ciphertext¶
In signed(encrypted(body)) the signature is computed over the ciphertext, not
over anything a reader will ever see. That proves the signer transmitted the
blob; it does not prove they wrote it, and it does not prove they can read it —
anybody who obtains an encrypted message can wrap it in their own signature and
forward it. Such a layer is recorded with "covers_ciphertext": true in
state.crypto.in.layers and, whatever its own verdict, it is never allowed
to make the message ``valid``: a good one is reported as valid-untrusted,
so it earns no [verified] tag and does not set
state.signature_verified. A bad one is still reported as invalid and
still routes under ON_BAD_SIGNATURE — only the positive verdict is withheld,
because only the positive verdict grants anything.
The consequence is worth stating plainly: in signed(encrypted(signed(body)))
— the triple wrap RFC 8551 §3.7 recommends — the inner signature is the one
that speaks for the content, and it is the one this stage reports. That shape is
therefore reported as valid where a single outer signature is not.
70.1.7.1.7. Failure handling¶
Both failure paths deliver by default, matching Pepsi’s existing and deliberate fail-open posture on inbound SPF, DKIM and DMARC:
ON_DECRYPT_FAILURE = passthroughThe still-encrypted message is delivered. The user may well hold the key in their own client, and bouncing mail we merely could not open helps nobody.
quarantineandbounceare available.ON_BAD_SIGNATURE = recordA bad signature is a recorded verdict, never a delivery failure. Mailing lists that rewrite bodies, forwarders and clients with broken canonicalisation all produce bad signatures on entirely legitimate mail; quarantining them would cost far more than it catches.
quarantineandbounceare available.
Note that only invalid is a “bad signature” for this purpose.
valid-untrusted and unverifiable are the normal state of mail from a
correspondent nobody here has heard of, and routing those anywhere would route
most of the internet.
The decryption policy is consulted first: a message that could not be opened has no signature verdict worth acting on.
70.1.7.1.8. Anti-forgery¶
Every X-Pepsi-* header field is removed from every message this stage sees —
including a message it is not going to touch, and including one for which the
stage is disabled. Only then is our own added.
This is not hygiene. X-Pepsi-Crypto is a private header, so it is forgeable
by definition; the removal is the entire basis on which a client may believe it.
The Pepsi-Origin proof-of-origin header is deliberately not in the
namespace and survives.
The same argument applies to the Subject. With STRIP_SUBJECT_TAGS = yes
(the default) this stage’s own tag vocabulary — TAG_DECRYPTED,
TAG_VERIFIED, TAG_BAD_SIGNATURE, the built-in [decrypted] and
[verified] whatever those were renamed to, and anything in
STRIP_SUBJECT_KEYWORDS — is removed from the front of an inbound subject
before ours is prepended. Otherwise an external sender simply writes
[verified] themselves. Tags are stripped from the front only, and from behind
a Re:/Fwd: prefix, so a subject that mentions one mid-sentence keeps it.
70.1.7.1.10. The result header¶
With ADD_RESULT_HEADER = yes (the default) a message that carried any
protection gains one header field, prepended at the top so it disturbs no
existing signature:
X-Pepsi-Crypto: encrypted=yes; decrypted=yes; protocol=openpgp;
signature=valid; signer=alice@example.net;
fingerprint=0123456789ABCDEF; key-source=wkd; trust=wkd-advanced;
layers="encrypted,signed"
The grammar is semicolon-separated key=value pairs in a fixed order. A value
that is not a bare token (A–Z, a–z, 0–9 and
. _ @ : + / -) is double-quoted; an embedded " becomes ', and control
characters are replaced by spaces, so no value from the message can end the field
or start another one. Long values are folded at whitespace.
The keys are:
encryptedyes/no— the message arrived carrying ciphertext.decryptedyes/no— present only whenencrypted=yes.protocolopenpgporsmime.signatureOne of the verdicts above.
signer,fingerprint,key-source,trust,algorithmThe signing key, where it came from and what it was worth. Present only when known.
failureThe machine-readable reason a signature verdict is not good.
decrypt-failureThe machine-readable reason decryption failed.
layersThe protection layers, outermost first, comma separated.
RFC 8601 registers no smime or pgp method, so an
Authentication-Results extension would mean inventing a method name in a
registry we do not own — which other implementations are entitled to ignore or
mistrust. A private header with a documented grammar is the honest choice, and
the price of it is the unconditional stripping described above.
70.1.7.1.11. Key discovery¶
The stage performs no network I/O of any kind. When the signer’s key is
neither in the message nor in the peer_key cache, it enqueues a discovery
request and parks the message; a pepsi-keydisc(1) service releases it when
the request settles, found or not, and the stage runs again. A settled request
with no key releases the message just the same, and it then verifies as
unverifiable. A fresh negative-cache entry short-circuits the wait entirely,
which is what stops a spammer signing with an unknown key from making every
message wait: the first one parks, the rest do not.
[pepsi-keydiscovery] INBOUND_TIMEOUT bounds the wait and is separately
tunable from the outbound TIMEOUT; DISCOVERY_TIMEOUT in this stage’s own
section overrides it. DISCOVERY = no suppresses the request altogether and
leaves the stage on the cache-only path.
Whether a parked message is stored decrypted depends on the shape of the message, and the difference matters:
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 is decrypted exactly once.
An OpenPGP signature lives only inside the packet stream, so it is gone the moment the plaintext is out. Committing the plaintext would destroy the evidence the key is being fetched to check, so such a message is parked unchanged and decrypted again on resume.
A message parked with its plaintext committed sits in pepsi.ingress
decrypted for up to the discovery deadline. That is the same exposure delivery
would create a moment later anyway; it is a longer window, not a new one.
70.1.7.1.12. Certificates the message carries¶
Most signed mail carries the certificate that signed it, so before deciding
anything the stage reads the S/MIME signer certificates and
application/pgp-keys parts out of the message and offers them to the
verifier. It does this again on the plaintext once the message is open, since a
certificate attached inside the ciphertext is invisible until then, and
re-evaluates once if that yielded something the first pass did not have.
These certificates are used and dropped. This stage stores nothing: learning
from inbound mail — the Autocrypt: header, attached keys, and gossip — is
pepsi-stage-autocrypt-learn(1), which runs later in the pipeline, after the
stages that decide whether a message is spam. That split is what lets Autocrypt
Level 1 §5.3’s spam rule be honoured at all: this stage runs first on the
inbound path by design, before any such verdict exists. It also means nothing is
written to the key store by the process that holds the private keys.
Using a certificate the message carried can only ever lower a verdict’s
standing, never raise it: such material is untrusted provenance, so a signature
checked against it comes back valid-untrusted and does not set
state.signature_verified — the key pepsi-stage-check-whitelist(1) gates
on. That is the same rung a stored harvested key would have had, which is why
routing it through the database bought nothing.
70.1.7.1.13. Two facts recorded for the learn stage¶
Decryption commits the plaintext over the bytes that arrived, so two things only
this stage can see are written to state.crypto.in for
pepsi-stage-autocrypt-learn(1) to read: encrypted, that the message
carried ciphertext at all, and outer_gossip, that the arriving header block
already had an Autocrypt-Gossip: field. Both are preconditions for believing
a message’s gossip, and neither is answerable once the plaintext is in the row.
70.1.7.1.14. Our own users’ keys are never overridden¶
Harvesting keys on the From: header, which the sender writes, and inbound
authentication is deliberately fail-open. A message claiming to come from one of
this deployment’s own users will therefore seed a peer_key row for that
user with whatever key it carried — and a signature checked against it would
otherwise verify, setting state.signature_verified, which
pepsi-stage-check-whitelist(1) gates on.
So before the store is consulted, the stage asks whether the claimed sender is
somebody it holds an identity for. If it is, that identity’s public key is the
only candidate: no peer_key row for the address is offered to the verifier,
whatever its rank and however it arrived. Anything but a revoked identity
counts, expired ones included — a signature made last year was made with last
year’s key, and refusing to look at it would report our own users’ mail as
unverifiable every time a key rolled over.
The rule is conditional on holding a key, not on serving the address: a user who runs their own OpenPGP client and never uploaded a key here has no identity, so their harvested or gossiped key is used exactly as any correspondent’s would be. The cost is the mirror image — once an identity is held for an address, a signature made with that user’s separate personal key no longer verifies. See the manual’s Our own users’ keys are not discovered.
70.1.7.1.15. Trust anchors¶
X.509 anchors come from the pepsi.ca_trust table (pepsi-keys(1) ca)
and are cached per worker process for TRUST_ANCHOR_TTL (default five
minutes). An anchor added or disabled therefore becomes effective within that
window; restart the workers to apply it at once. They are loaded lazily and only
for a message that actually carries an S/MIME layer, so ordinary traffic costs no
query.
Revocation data is not fetched: a CRL retrieval would be network I/O in a
stage, which the discovery design exists to avoid. A chain is consequently
reported as revocation not-checked, which is stated rather than being allowed
to imply “not revoked”.
70.1.7.1.16. Configuration¶
The stage reads its own [stage-<name>] section (PROGRAM =
pepsi-stage-decrypt, NEXT_STAGE (required), 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, DISCOVERY_TIMEOUT), all documented in
pepsi.conf(5).
Those are behavioural options and are per-address overridable through the
pepsi.settings table.
The cryptographic policy — algorithm choice, weak-digest acceptance, minimum
key sizes — lives in [pepsi]’s CRYPTO_* options and [pepsi-crypto].
Those are system-administrator-only and are deliberately not reachable from
pepsi.settings or pepsi-stage-edit-settings(1).
One of them does not apply here. CRYPTO_ALLOW_DOWNGRADE governs what may
be emitted: OpenPGP SEIPDv1+MDC and CMS EnvelopedData are always readable
whatever it says, because refusing them inbound would make this host unable to
receive mail from most of the world while protecting nobody. Only 3DES and the
weak signature digests are gated inbound, and each refusal produces its own
verdict rather than a generic failure. The container that was actually used is
recorded per layer, so a deployment can see how much of its inbound mail is still
pre-AEAD.
70.1.7.1.17. Privilege¶
The binary is installed setuid pepsi-crypto, mode 4750
pepsi-crypto:pepsi, and is therefore not folded into the unified pepsi
multi-call binary.
Both halves of the key-custody boundary key on that identity. The database role
pepsi-crypto is the only one granted crypto_identity.private_wrapped, and
PostgreSQL peer authentication keys off the effective uid; the
key-encryption key lives in secrets.d/pepsi-crypto.secret, mode 0640
owned by the same account. A setgid binary would gain the group — enough to
read the fragment — and would still authenticate to the database as pepsi,
precisely the role that is denied the column.
The group in the mode restricts execution to the dispatcher’s account and root;
the program also refuses at run time unless its real uid is one of those. -c
is accepted from those callers (the dispatcher’s documented spawn shape is
PROGRAM -c cfg worker) and the environment is sanitised before the
configuration is loaded, because the configuration layer is steerable through
XDG_CONFIG_HOME, HOME, PATH and the PG* family.
Decryption keys are selected by capability: every non-revoked identity of every
local recipient whose purpose is encrypt or both, including expired
ones, because old mail must stay readable.
70.1.7.1.18. Resource limits¶
MAX_LAYERS (default 4) caps how many nested encrypted layers are unwrapped; a
message with more is delivered with the remainder unopened and
decrypt-failure=unsupported recorded.
An OpenPGP message is routinely compressed and the expansion ratio is the
sender’s choice, so pepsi-crypto refuses a plaintext over 64 MiB outright
rather than truncating it — a partial plaintext must never be reported as
verified. That case is reported as its own verdict, too-large, and not as a
generic decryption failure: “this message is a bomb” and “we could not open this”
are different facts for an operator.
70.1.7.1.19. State¶
Inputs: state.dsn (honoured and sliced in lockstep with the locality
split), state.crypto.in (on a resumed row, what the pass that decrypted it
recorded).
Outputs: state.crypto.in — whether the message was encrypted, what became
of the ciphertext, which identity opened it, the signature verdict and its
detail, and the ordered layer list. Also state.encrypted = true when the
message arrived encrypted, and state.signature_verified = true for the
valid verdict only; pepsi-stage-check-whitelist(1) and
pepsi-stage-auto-whitelist(1) read those. On a bounce, state.bounce.
The state layout is described in pepsi.state(7).
Transitions: NEXT_STAGE for a delivered message; QUARANTINE_STAGE or
BOUNCE_STAGE per the failure policies; paused while waiting for key
discovery; pending at this same stage after a locality split.
70.1.7.1.20. Commands¶
- worker
Run as a persistent dispatcher worker, reading message ids on standard input and writing one status line per message. This is the only mode; there is no one-shot form. To run a single message by hand, pipe its id:
echo 1234 | pepsi-stage-decrypt -c /etc/pepsi/pepsi.conf worker
70.1.7.1.21. Exit Status¶
- 0
The worker exited cleanly (standard input closed).
- 1
An error occurred (misconfiguration, an unreadable key store, or a database error). The reason is written to the log.
70.1.7.1.22. See Also¶
pepsi-stage-encrypt(1), pepsi-stage-arc(1), pepsi-keys(1), pepsi-keydisc(1), pepsi-stage-check-whitelist(1), pepsi-stage-relay-to-maildir(1), pepsi-stage-relay-to-lmtp(1), pepsi-stage-bounce(1), pepsi-dispatch(1), pepsi-setup(1), pepsi.conf(5), pepsi.state(7)
70.1.7.1.23. Bugs¶
Report bugs to the Pepsi issue tracker.