85.1.6. pepsi-stage-encrypt

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

Manual section:

1

85.1.6.1.1. Name

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

85.1.6.1.2. Synopsis

pepsi-stage-encrypt [GLOBAL-OPTIONS] worker

85.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. 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 why encryption is last.

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

An encrypting subject keyword (SUBJECT_KEYWORDS_ENCRYPT or _BOTH) 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.

With ENABLE_PEP = no, key material is created only when a message asks for it: an explicit request of any kind (X-Pepsi-Sign/X-Pepsi-Encrypt other than no, or any subject keyword – an [encrypt] keyword under SIGN = no included), or SIGN = always unless X-Pepsi-Sign: no refused the signature. When [pepsi-crypto] AUTO_CREATE_IDENTITY is on, a KEY_WRAP_SECRET is configured, the From: domain is one of [pepsi-ingress] ACCEPTED_DOMAINS and the author has no identity of any kind, one is generated inline, in the protocol of the recipient’s key when the message is being encrypted and in PREFER’s protocol otherwise. 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. Under the default ENABLE_PEP = yes a key is instead created on the sender’s first submission, request or not; see The pEp preset below.

Whichever way it is created, the identity’s owner is the passwd login that [pepsi-crypto]’s LOCAL_DOMAINS, TARGETS and RECIPIENT_DELIMITER resolve the address to, exactly as for pepsi-keys(1); none when the address is a role address or a hosted domain.

Either way, automatic creation is refused for an address that already has a crypto_identity row of any kind – an MUA key registered by the user, an imported public-only key, or a revoked key. A [sign] keyword from a user who brought their own key therefore does not mint a second key behind their back; a user who wants a server-managed key next to their own asks for one explicitly (the generate command below, pepsi-keys identity generate, or the web console).

A key that should be created and cannot be – the database refuses the write, generation itself errors – is logged at warn, and the message proceeds as for an address with no key: its policy decides whether it goes unsigned or is refused. The user did not ask for the key to be made, and holding their mail hostage to a broken key store is worse than sending it without one. (A deployment with no KEY_WRAP_SECRET creates no keys at all and is not affected.) Registering the key a user’s client advertises is best-effort in the same way.

Once a signing key exists and cannot be opened (an unreadable key-encryption key, a revoked grant), the message is not sent unsigned: that error is retried until the stage’s MAX_LIFETIME (see pepsi-dispatch(1)), like every other fault of this host, since sending unsigned would hide a broken deployment. The stage’s section, the [pepsi] crypto policy, [pepsi-keydiscovery] and, when a key-encryption key is configured, the key ring built from it are checked when the worker starts: one that does not work makes the worker refuse to start (exit 78), and the dispatcher holds the queue until it is fixed.

85.1.6.1.3.2. The pEp preset

ENABLE_PEP (default yes) makes the stage behave like the pretty Easy privacy (pEp) project: every local sender gets a key, mail is encrypted whenever a recipient’s key is known or discoverable, and the sender’s public key goes out with every message. It is a preset of defaults, not a forcing mode: it changes the defaults of other options, and any option written out explicitly in the section (or in a per-address pepsi.settings override) still wins.

Behaviour

ENABLE_PEP = yes

ENABLE_PEP = no

SIGN default

encrypted-only

opportunistic

Key creation

eager (first submission)

on request, or SIGN = always

ENCRYPT default

opportunistic

opportunistic

AUTOCRYPT default

yes

yes

ATTACH_KEYS_AS_FILES

no (independent)

no (independent)

The preset concerns only mail users send: under it, cleartext mail from a user who holds a key is not signed by Pepsi, and senders get keys they did not ask for. What anyone can receive does not depend on it: inbound mail is handled the same either way.

The standard wire formats are unchanged: the messages are ordinary PGP/MIME and Autocrypt. There are no X-pEp-Version headers, no pEp 2.x message wrapping, no trustwords or handshake, and no key synchronisation between devices, and the preset does not change PROTECT_HEADERS.

Eager key creation. On every committed locally submitted message – the cleartext pass-through as well as the protected route, so that the first message already carries the new key in its Autocrypt: header – the stage generates an OpenPGP MTA key for the From: address when all of these hold:

  • ENABLE_PEP is on for the message (it is per-address overridable);

  • the From: domain is one of [pepsi-ingress] ACCEPTED_DOMAINS: a key is only ever made for an address whose inbound mail comes through this deployment, which is what lets pepsi-stage-decrypt(1) open replies encrypted to it;

  • [pepsi-crypto] AUTO_CREATE_IDENTITY is on – the operator’s veto, which no per-address setting can override;

  • a [pepsi-crypto] KEY_WRAP_SECRET is configured;

  • the address has no crypto_identity row of any kind, whatever its status, protocol or custody. A revoked key counts: if somebody revoked a key, Pepsi does not quietly mint a replacement.

This covers every submission route, including mynetworks and client-cert relays, so a Pepsi deployed as a proxy behind an MTA that authenticates the users works the same way. A web application sending as noreply@ a domain Pepsi does not receive mail for gets no key. Two workers seeing the same first-time sender at once store exactly one key: the test and the insert run under a per-address lock in the SQL function crypto_identity_create_if_absent, and the loser discards what it generated. An automatically created key is marked auto_created, published over the deployment’s own WKD, and never uploaded to a key server unless its owner asks (vks_wanted = false; see pepsi-keys(1)).

SIGN = encrypted-only (the preset’s default) signs a message only when this stage encrypts it, and then inside the ciphertext. Cleartext mail carries the sender’s key (the Autocrypt: header, plus the key file when ATTACH_KEYS_AS_FILES is on) but no signature, so a recipient who does not use cryptography never sees a signature.asc. An explicit request (X-Pepsi-Sign: yes, a [sign] subject keyword) still signs cleartext. Writing SIGN = opportunistic keeps sign-everything behaviour under the preset.

The first message to a new correspondent may still wait for key discovery up to DISCOVERY_TIMEOUT and then go out in cleartext; the preset does not change discovery.

85.1.6.1.3.3. Two keys per user: MTA keys, MUA keys and the public face

An address may hold two kinds of identity:

MTA key

custody = local: Pepsi holds the private half (wrapped in crypto_identity.private_wrapped). Pepsi signs, decrypts and advertises with it. Generated automatically, on request, or imported with its private half.

MUA key

custody = client: the user’s own key, whose private half lives only in their mail client. The row is public-only for ever. Pepsi encrypts to it and recognises inbound mail encrypted to it, but can never open, sign with or advertise it.

The public face of an address is the key a correspondent should use: the newest active MUA key when the user has one, otherwise the primary active MTA key that still holds its private half. The Autocrypt: header, the key file, the deployment’s WKD and local encryption all read it through the same ordering, so they cannot disagree:

  • WKD serves only the MUA key when there is one;

  • mail from one local user to another is encrypted to the public face;

  • Pepsi adds no Autocrypt: header and attaches no key file when the public face is an MUA key. The mail client advertises its own key, and Pepsi advertising a different one on some of the same user’s messages would make correspondents’ key state flip under Autocrypt’s newest-wins rule.

The MTA key is not retired when an MUA key appears. It keeps decrypting mail from correspondents who still use it, and it still signs, inside the ciphertext, cleartext mail that Pepsi encrypts on the user’s behalf. Inbound mail encrypted to an MUA key is passed to the mail client unopened; see pepsi-stage-decrypt(1).

85.1.6.1.3.4. Registering the user’s own key

A locally submitted message whose From: address is at a served domain can register the key its mail client uses as an MUA key. Registration does not depend on ENABLE_PEP. The key is taken from:

  1. the message’s single valid Autocrypt: header, whose addr= must equal the From: address; or, failing that,

  2. an attached application/pgp-keys part whose User IDs name the address – but only for the address’s very first key. An attachment is a weaker signal than a header the mail client manages, so a second key is registered only from Autocrypt:.

It happens only when the submitter may speak for the From: address, which is stricter than being allowed to send as it: the message arrived from a trusted relay (state.origin.auth_method is mynetworks or client-cert), or from an authenticated account (sasl, peercred) for which ingress recorded state.origin.from_bound = true – the From: is exactly one mailbox that matched a concrete, wildcard-free USERNAME_MAP entry of the account, or its default login@HOSTNAME. An account granted a wildcard (*@example.org, *) may send as its colleagues but never registers a key for them.

A fingerprint the address already has, retired or not, is not registered again, so a key the user retired does not come back because an old device still sends it. A new MUA key is stored with vks_wanted = false and becomes the public face at once. When the address already had another identity, the user is sent a null-sender notice (Auto-Submitted: auto-generated, the key-registered.<lang>.body template) at RESPONSE_STAGE, naming the fingerprint and saying how to retire it – so a key added with a stolen password does not go unnoticed. With no RESPONSE_STAGE the key is still registered but no notice can be sent. The message itself goes on unchanged apart from the usual header hygiene.

An address that has enrolled a second factor (otp enroll, below) registers nothing this way: a message cannot carry a code, and this path is exactly what a stolen password would use. Its owner registers a new key with register CODE instead.

85.1.6.1.3.5. Mail the client already protected

Only the outermost layer of the submitted message is consulted:

Already encrypted

PGP/MIME, an enveloped S/MIME application/pkcs7-mime, an inline -----BEGIN PGP MESSAGE, a signature wrapped around ciphertext, or anything no classifier could identify. The stage encrypts, signs and attaches nothing, and adds no Autocrypt: header. Header hygiene and the MUA-key registration above still run. state.crypto.out.client_protected = true is recorded.

Already signed (multipart/signed, inline PGP signed)

The stage may still encrypt it, as encrypted(signed), but adds no signature of its own and no cleartext key file.

A forwarded encrypted message attached as message/rfc822 is somebody else’s protection and does not count.

85.1.6.1.3.6. Attaching the key as a file

ATTACH_KEYS_AS_FILES (default no, independent of the preset) also attaches the sender’s public key the classic OpenPGP way, in addition to the Autocrypt: header, for correspondents whose client does not read Autocrypt. The part is application/pgp-keys, named OpenPGP_0x<long key ID>.asc (the last 16 hex digits of the fingerprint, the name Thunderbird uses), holding the same minimal certificate the header carries, ASCII-armoured.

  • On the encrypting route it goes inside the ciphertext, placed like gossip.

  • On the cleartext route the committed message is wrapped in a new multipart/mixed holding the original entity and the key part, or the key becomes one more part of an existing multipart/mixed. The stage runs before pepsi-stage-dkim-sign(1), so the deployment’s DKIM signature covers the result.

  • It is attached until every recipient of the row provably holds the key (the pepsi.peer_has_own_key record that pepsi-stage-autocrypt-learn(1) writes when a correspondent sends an encrypted and signed reply). The record is keyed on the fingerprint, so a new key starts attaching again. A multi-recipient cleartext row is not split for this: the key goes to all if any recipient lacks proof.

  • Never when the public face is an MUA key, never on a message the client encrypted, and not on cleartext the client signed. A message already carrying the key’s file is not given a second one.

state.crypto.out.key_attached = true records that the file went out.

85.1.6.1.3.7. Operating your keys by e-mail

With RESPONSE_STAGE set and KEYS_CONTROL_LOCAL_PART not none, a locally submitted message whose only recipient is <KEYS_CONTROL_LOCAL_PART>@<served domain> (default pepsi-keys@) is a key command. It is consumed – deleted, never relayed – and answered at RESPONSE_STAGE with the keys.<lang>.body template, which lists the address’s keys. The command is the Subject:, case-insensitive, with any Re:/Fwd: prefixes ignored:

status

List the address’s keys: kind (server-managed or the user’s own), status, key-server state, and which one is given to correspondents.

generate

Create a server-managed OpenPGP MTA key. Always allowed on request, even next to the user’s own key or a revoked one. Its key-server upload is requested when [pepsi-keys] VKS_PUBLISH is on.

register

Register the key in this message’s Autocrypt: header, or its attached key file, as the user’s own MUA key.

publish [FPR]

Ask for an upload to the key server (and publish over WKD), by default of the public face. Only an active OpenPGP key can be uploaded. The upload itself is made by the next run of pepsi-keys identity publish --retry, and cannot be undone.

retire FPR

Revoke a key; also how a wrongly registered MUA key is removed.

otp enroll

Enrol (or replace) a second factor for these commands: a TOTP secret (RFC 6238, SHA-1, six digits, 30-second steps). The reply carries it once – as an otpauth:// URI, in base32, and as an attached QR code (second-factor.png) for an authenticator app – and should be deleted once the secret is in the app. Replacing an existing one needs its current code. Being the only copy of the secret, the reply is committed in one transaction with the secret it carries, and rendered before either: if it cannot be built or queued, nothing is stored, the previous second factor stays as it was, and the command is retried.

otp remove

Remove the second factor; needs its current code. otp alone (or otp status) is status.

With a second factor enrolled, every command but status must carry the app’s current code: a word of exactly six digits anywhere in the Subject: (register 123456; otp=123456 is accepted too). A command without one is refused and not counted. A wrong code, or a code already used (a code is valid once), counts as a failure and a right one resets the count; after ten failures in a row the second factor is locked and every command but status is refused until the operator resets it (pepsi-keys otp reset). The secret is wrapped under the key store’s key-encryption key and readable by the pepsi-crypto role only; see pepsi-keys(1). The reply to status says whether the address has one and whether it is locked.

A command is answered once. A command message whose reply is already queued (an earlier pass carried it out and was interrupted before consuming it) is only consumed, so a retry never repeats a command – in particular never mints a second secret behind the reply that carries the first. A command that fails for a reason of this server is answered with a generic “try again later”; the error itself is in the log, not in the mail.

FPR is the full fingerprint or at least its last 16 hex digits, written as one word (a 0x prefix is accepted); being at least 16 digits, it is never mistaken for a code. list, upload and revoke are accepted for status, publish and retire, and 2fa/totp for otp; any other subject is answered with the key list and the valid commands. The command acts on the From: address, and only when the submitter may speak for it (see Registering the user’s own key); otherwise, and for an address at a domain the deployment does not serve, the reply says the request was refused. With no RESPONSE_STAGE there is nowhere to answer, so the surface is off and such a message is sent on like any other.

85.1.6.1.3.8. 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 the exception does not apply to them. 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.

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

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

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

  • Only the public face. When the user registered their own MUA key, it is the public face and Pepsi advertises nothing; the mail client advertises its key itself. A message the client already encrypted gets no header either.

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 before pepsi-stage-dkim-sign(1), the header is covered by the deployment’s own DKIM signature.

85.1.6.1.3.11. 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 floor is the stricter of MIN_TRUST and GOSSIP_MIN_TRUST (default owner-confirmed): a key that would not have been good enough to encrypt to is not good enough to pass on either, and by default a key this deployment merely harvested from inbound mail — or was itself gossiped — is not republished at all. Introducing a correspondent to a key nobody vouched for is a stronger claim than using it oneself.

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

85.1.6.1.3.12. 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).

85.1.6.1.4. Configuration

The stage reads its own [stage-<name>] section (PROGRAM = pepsi-stage-encrypt, a required NEXT_STAGE — every outcome this stage has is an onward hop — and BOUNCE_STAGE, which pepsi-setup(1) in turn requires whenever ON_NO_KEY/ON_OVERSIZE resolves to bounce, plus ENABLE_PEP, SIGN, ENCRYPT, ATTACH_KEYS_AS_FILES, KEYS_CONTROL_LOCAL_PART, RESPONSE_STAGE, PREFER, ON_NO_KEY, ON_OVERSIZE, MIN_TRUST, SUBJECT_KEYWORDS_*, STRIP_REQUEST_HEADERS, PROTECT_HEADERS, AUTOCRYPT, AUTOCRYPT_GOSSIP, GOSSIP_MIN_TRUST, 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).

85.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 root or one of the pepsi, pepsi-crypto and pepsi-owner accounts. That run-time check applies only when the binary actually carries the setuid bit — a build installed without it (a developer tree, cargo test) lets anyone run it, on the grounds that such a caller reaches the database as themselves and PostgreSQL’s own grants are then the authority. -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.

85.1.6.1.6. State

Inputs: state.local_origin (the gate), state.dsn (honoured and sliced in lockstep with any recipient split), state.origin.auth_method and state.origin.from_bound (whether the submitter may speak for the From: address, for key registration and the e-mail key commands).

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; key_attached = true when the sender’s key went out as a file, and client_protected = true when the submitting client had already encrypted the message and the stage left it alone. 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. A key command to the control address is deleted (finish) after its reply has been injected at RESPONSE_STAGE; the key-registered notices are injected there too, as new null-sender messages.

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

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

78

The worker refused to start: its configuration or key ring does not work. The dispatcher requeues what it handed over and retries the stage later.

85.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)

85.1.6.1.10. Bugs

Report bugs to the Pepsi issue tracker.