70.1.6. pepsi-stage-encrypt

sign outgoing mail as its author and encrypt it to each recipient

Manual section:

1

70.1.6.1.1. Name

pepsi-stage-encrypt - outbound end-to-end signing and encryption stage of the Pepsi pipeline.

70.1.6.1.2. Synopsis

pepsi-stage-encrypt [GLOBAL-OPTIONS] worker

70.1.6.1.3. Description

pepsi-stage-encrypt is a stage program run by pepsi-dispatch(1) as a persistent worker reading message ids on standard input. For every locally submitted message it decides what protection to apply, applies it, and routes each recipient onward.

The name understates the stage: it signs as well as encrypts, in that order (sign inside, encrypt outside). Signing uses the key of the From: author, never the envelope sender — the signature is about the author the recipient sees, and a signature whose identity does not match is what verifiers flag. If the From: address is not one this deployment serves, or holds no signing material, the message is sent unsigned rather than bounced: an unservable author is still legitimate mail, and DKIM already carries the domain-level claim.

Encryption is per recipient. Every recipient that gets ciphertext gets their own ciphertext and their own queue row: no recipient-list disclosure, no content key shared between recipients, and Bcc leakage is structurally impossible rather than carefully avoided. Grouping S/MIME recipients into a single EnvelopedData would be cheaper and is what the format is for, but it reveals the recipient set to each recipient and lets one compromised key open the message for all of them.

The stage performs no network I/O of any kind. A recipient whose key is not in the cache pauses the message and enqueues a discovery request; a pepsi-keydisc(1) service releases it when the request settles, whether or not a key was found. A recipient with a fresh negative cache entry takes the no-key path immediately, with no pause, because there is nothing to wait for.

A message this deployment did not originate (no state.local_origin) is advanced untouched. Encrypting a relayed third-party message would invalidate the originator’s DKIM signature and ARC chain, and signing it as one of our users would be a lie. Its X-Pepsi-* request headers are still stripped, so an outside sender cannot leave Pepsi’s own control channel on the wire.

Warning

Place this stage before pepsi-stage-dkim-sign(1), not after.

The recommended outbound chain is:

submission -> ... -> encrypt -> srs -> dkim-sign -> relay

DKIM must sign the bytes that are actually transmitted. If DKIM signing runs first, this stage rewrites the body underneath the signature and the result is mail that looks perfectly valid and whose signature does not verify — which is worse than no signature at all, because a broken signature is a stronger negative signal to a receiver than an absent one. pepsi-setup(1) warns when it finds a DKIM-signing stage that leads to this one.

Two other neighbours want cleartext and get it: the pay-to-send gate (pepsi-stage-anti-spam(1)) and language detection (pepsi-stage-detect-language(1)) read the message body, and on the outbound path they run before this stage. That is not luck to be preserved — it is why encryption is last.

70.1.6.1.3.1. Policy resolution

What protection a message asks for is resolved from four inputs, most specific first:

  1. the state.local_origin gate — nothing below it applies to a message we are merely forwarding;

  2. the request headers an add-in set (X-Pepsi-Sign, X-Pepsi-Encrypt, each yes / no / required);

  3. a subject keyword (SUBJECT_KEYWORDS_SIGN / _ENCRYPT / _BOTH);

  4. the [stage-encrypt] defaults SIGN and ENCRYPT, which already carry any per-address override from the pepsi.settings table.

The two request headers are stripped from the outgoing message whatever they say and whatever STRIP_REQUEST_HEADERS is set to: a control channel that survives onto the wire is a control channel the next hop can be told to obey. A matched subject keyword is likewise removed from the Subject.

A subject keyword or an X-Pepsi-Encrypt: required header raises the policy to required for that message, and required never degrades to cleartext — if the resolved ON_NO_KEY says cleartext, it is raised to the secure link (or a bounce) for that message. A user who explicitly asked for protection is not silently ignored.

An explicit request is also the only thing that creates key material: when [pepsi-crypto] AUTO_CREATE_IDENTITY is on and the author has no identity, one is generated inline. An address that merely appeared in an envelope never triggers this, so a user who never uses the feature never has key material and is never exposed by it.

70.1.6.1.3.2. Choosing the recipient’s key

Normally the key comes from the pepsi.peer_key cache, best-ranked source first and subject to MIN_TRUST — see pepsi.conf(5) and pepsi-keydisc(1).

There is one exception, and it is a security boundary rather than an optimisation. If this deployment holds an ``active`` identity for the recipient, that key is used and no ``peer_key`` row for the address is consulted at all — whatever its rank, and however it arrived. state.crypto records the source as own.

The reason is that peer_key is fed in part by harvesting inbound mail, and harvesting keys on the sender-written From: header. Inbound authentication is deliberately fail-open, so a message claiming to be from one of this deployment’s own users can seed a peer key for that user with a stranger’s key in it; without the exception, mail from one local user to another would then be encrypted to the planted key and would look exactly like success.

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, and their real key is one this host can only learn by discovery, harvesting or gossip — so for them nothing changes. See the manual’s Our own users’ keys are not discovered for the trade-off in full, including its cost: a signature made with a user’s separate personal key stops verifying once an identity is held for that address.

The same preference applies to the gossip fields below: a subject of ours is introduced with the key we issued them, never with one somebody planted.

70.1.6.1.3.3. Recipient outcomes

Per recipient, exactly one of:

encrypted

A usable key was found (subject to MIN_TRUST) and the message was encrypted to it. The container actually emitted is recorded, together with whether it was a downgrade from the AES-256-GCM default.

signed-only

No usable recipient key, policy allows ordinary mail, and the author’s signature was applied.

cleartext

Neither signed nor encrypted.

secure-link

Routed to SECURE_LINK_STAGE instead of being sent.

bounced

Encryption was required and could not be applied; routed to BOUNCE_STAGE with an RFC 3463 5.7.0 enhanced status. With no BOUNCE_STAGE the message is failed rather than sent unprotected.

oversize

The message was larger than MAX_SIZE and took the ON_OVERSIZE route.

When the recipients of one message do not all share an outcome, the stage splits them one per row (each row’s state.dsn.rcpt sliced in lockstep with its rcpt_to) and hands the message back to the queue; each row then re-enters this stage alone. A message whose recipients do all share an outcome — the common case of “nobody has a key, send it as usual” — is never split.

Every outcome that puts unprotected content on the wire while encryption was wanted is logged at WARN and recorded per recipient in state. Sending cleartext is never a silent outcome.

70.1.6.1.3.4. Autocrypt key advertisement

Unless AUTOCRYPT is turned off, every locally submitted message leaves this stage carrying an Autocrypt: header advertising the From: author’s own OpenPGP key:

Autocrypt: addr=alice@example.org; prefer-encrypt=mutual;
 keydata=xjMEZ8kAABYJKwYBBAHaRw8BAQdA…

This happens on every message — signed, encrypted, routed to the secure link or plain cleartext. The pass-through case is the one that matters: a correspondent has to be able to learn the key from ordinary mail, because until they have it there is nothing to encrypt with. Advertising only on already protected mail would tell the key to exactly the people who already know it.

The advertised key is the minimal transferable public key of Autocrypt Level 1 §2.1.1 — primary key, primary User ID, its self-signature, one transport-encryption subkey and its binding signature, and nothing else. Extra User IDs, the signing subkey and any third-party certifications are stripped: the header rides on every message, so its size is paid over and over, and republishing certifications would publish the sender’s social graph as a side effect of sending mail.

Four rules bound it:

  • OpenPGP only. Autocrypt is defined over an OpenPGP transferable public key and has no S/MIME form, so a sender holding only an X.509 identity advertises nothing.

  • Locally submitted mail only, like everything else this stage does.

  • Never over an existing header. A real client (Thunderbird, K-9, Delta Chat) may have set its own Autocrypt: field naming the key it holds the secret half of. That one is better than ours and is left alone — and a second field would be worse than useless, because Autocrypt Level 1 discards a message carrying two of them outright.

  • The key must still be openable here. Only an identity that still holds its private material is advertised, so ciphertext sent to an advertised key is always ciphertext pepsi-stage-decrypt(1) can open.

prefer-encrypt=mutual is emitted only when the stage’s effective ENCRYPT is required — including when that comes from a per-address pepsi.settings override, so one user can advertise it without the deployment doing so. Under Autocrypt Level 1 §2.4 a correspondent’s client enables encryption by default only when both ends say mutual; claiming it under opportunistic, where ON_NO_KEY = cleartext says cleartext is an acceptable outcome, would announce a preference this deployment does not enforce. The attribute is then omitted, which Level 1 reads as nopreference: the correspondent still records the key and is still offered encryption, only not by default. The value follows the configured mode rather than the one resolved for a single message, so a [secure] subject keyword does not make a correspondent’s key state oscillate with our users’ subject lines.

Because this stage runs immediately before pepsi-stage-dkim-sign(1), the header is covered by the deployment’s own DKIM signature.

70.1.6.1.3.5. Autocrypt key gossip

AUTOCRYPT_GOSSIP (default NO) turns on the other half of Autocrypt Level 1: Autocrypt-Gossip: fields advertising the other recipients’ keys. It answers the case the header above cannot. Alice writes to Bob and Carol, who have never corresponded. Both learn Alice’s key from her Autocrypt: header, but neither learns the other’s — so Bob’s reply-all cannot be encrypted to Carol until some later message happens to introduce them. One gossiped message closes that gap:

Autocrypt-Gossip: addr=carol@example.org;
 keydata=xjMEZ8kAABYJKwYBBAHaRw8BAQdA…

It is off by default because it is a different kind of act from AUTOCRYPT: that one publishes a key its owner asked this deployment to hold, while this one redistributes third parties’ key material — cached here for our own use — to correspondents who never asked for it. That is an operator’s decision.

Three rules follow from the specification, and one from this stage’s own shape.

  • Only inside a ciphertext. Level 1 §5.3 puts these fields in the header section of the encrypted part, and not as a nicety: an outer Autocrypt-Gossip: field would publish the recipient set of an encrypted message to every relay on the path, which is most of what the encryption was for. So there is no gossip on the cleartext pass-through, none on a message that was only signed, and none on an S/MIME container — Autocrypt is defined over OpenPGP and has no S/MIME form, so a gossip field there would be redistribution into a place no client looks.

  • Several are expected. One field per introduced recipient, in To:/Cc: order — the opposite of Autocrypt:, where a second field makes a Level 1 parser discard the message outright.

  • No ``prefer-encrypt``. The attribute states the sender’s policy, and these fields speak for somebody else. Asserting one on a correspondent’s behalf is a claim this deployment has no standing to make, and Level 1 leaves it out of the gossip form for the same reason.

  • A cached key or nothing. A visible recipient this deployment holds no usable OpenPGP key for is left out of the set. The stage performs no discovery on their behalf and never pauses a message to fetch a third party’s key: an introduction is a courtesy, and delaying the sender’s mail to perform one would not be. The MIN_TRUST floor is the stage’s own, so a key that would not have been good enough to encrypt to is not good enough to pass on either.

Warning

A blind carbon copy is never gossiped. The subject addresses come from the message’s parsed To: and Cc: fields and from nowhere else. The envelope recipient list — which does contain the Bcc recipients, that being the entire meaning of a blind copy — is consulted only to remove an address from the set (this copy’s own recipient, and the From: author).

A Bcc recipient therefore cannot appear in any copy of the message, and the guarantee does not depend on a Bcc: field having been stripped upstream: the field is not read at all. Level 1 §5.3 requires the same of a receiving client, which ignores a gossip header whose addr is not a To:/Cc: recipient — so such a header would leak the address and achieve nothing.

The converse is deliberate. A blind recipient’s own copy does carry gossip about the To:/Cc: recipients: they received those header fields like everybody else, so it tells them nothing they cannot already read, and withholding it would break reply-all for exactly the recipient least likely to have corresponded with the others before.

A message with more than 50 visible recipients gets no gossip at all. Each introduced key is repeated in every recipient’s own ciphertext (decision D4 gives each their own), so the cost grows with the square of the address count, and at that size the message is a broadcast where nobody will reply-all. Introducing an arbitrary fifty of them would be a silent choice with no defensible rule behind it, so the stage introduces none and says so in the log.

70.1.6.1.3.6. Content-algorithm negotiation

For OpenPGP the recipient’s certificate states whether it can read RFC 9580 SEIPDv2/AEAD; most of the installed base (GnuPG 2.4 and older) cannot. Under [pepsi] CRYPTO_ALLOW_DOWNGRADE = negotiate (the default) the stage emits SEIPDv1+MDC only when the recipient’s own key proves it cannot do better, logs it at INFO, and records it — a downgrade is a fact about the correspondent, not a failure. Under no the same recipient is refused and falls into ON_NO_KEY; see the caution under that option.

S/MIME has nothing to negotiate against — an X.509 certificate advertises no content-encryption capability — so it emits RFC 5083 AuthEnvelopedData unless CRYPTO_ALLOW_DOWNGRADE = yes forces AES-256-CBC. On an installed Pepsi that is the shipped default: ${DATADIR}/config.d/thunderbird.conf sets yes, because Thunderbird 140.12.0esr cannot read AuthEnvelopedData and fails silently when it tries. Deleting that file restores the compiled-in negotiate, and with it AEAD. The OpenPGP behaviour above is the same under either value — the container request is auto, which both resolve per recipient — so the S/MIME default costs OpenPGP nothing. See pepsi.conf(5).

70.1.6.1.4. Configuration

The stage reads its own [stage-<name>] section (PROGRAM = pepsi-stage-encrypt, NEXT_STAGE, BOUNCE_STAGE, plus SIGN, ENCRYPT, PREFER, ON_NO_KEY, ON_OVERSIZE, MIN_TRUST, SUBJECT_KEYWORDS_*, STRIP_REQUEST_HEADERS, PROTECT_HEADERS, AUTOCRYPT, MAX_SIZE, SECURE_LINK_STAGE, 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, downgrade permission, 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).

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

70.1.6.1.6. State

Inputs: state.local_origin (the gate), state.dsn (honoured and sliced in lockstep with any recipient split).

Outputs: state.crypto.out — whether the message was signed, by which identity, and one entry per recipient carrying the outcome, protocol, fingerprint, key source, trust rank, content algorithm and downgrade flag. Also state.encrypted = true when a recipient’s message was encrypted, which pepsi-stage-auto-whitelist(1) reads. On a bounce, state.bounce. The state layout is described in pepsi.state(7).

Transitions: NEXT_STAGE for a sent message (encrypted, signed-only or cleartext); SECURE_LINK_STAGE or BOUNCE_STAGE for a recipient that could not be protected; paused while waiting for key discovery; pending at this same stage after a recipient split.

70.1.6.1.7. 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-encrypt -c /etc/pepsi/pepsi.conf worker

70.1.6.1.8. 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.6.1.9. See Also

pepsi-stage-dkim-sign(1), pepsi-stage-srs(1), pepsi-keys(1), pepsi-keydisc(1), pepsi-stage-auto-whitelist(1), pepsi-stage-bounce(1), pepsi-dispatch(1), pepsi-setup(1), pepsi.conf(5), pepsi.state(7)

70.1.6.1.10. Bugs

Report bugs to the Pepsi issue tracker.