85.1.8. pepsi-stage-dkim-sign¶
DKIM-sign a message in the Pepsi pipeline
- Manual section:
1
85.1.8.1.1. Name¶
pepsi-stage-dkim-sign - DKIM-signing stage of the Pepsi pipeline.
85.1.8.1.2. Synopsis¶
pepsi-stage-dkim-sign [GLOBAL-OPTIONS] worker
85.1.8.1.3. Description¶
pepsi-stage-dkim-sign is a stage program run by pepsi-dispatch(1)
as a persistent worker reading message ids on standard input. It prepends one DKIM-Signature header per
algorithm in [pepsi] DKIM_ALGORITHMS (by default two: one RSA, one Ed25519)
to the message’s raw bytes and advances the row to its
NEXT_STAGE (a delivery stage). Signing only prepends header lines, so any
existing signatures lower in the message are undisturbed. It does not touch the
row’s state, so any RFC 3461 state.dsn parameters are preserved for later
stages to honour.
The signing domain (d=) is SIGNING_DOMAIN if configured, otherwise the
domain of the message’s From: header. The keys are the per-domain DKIM keys
under the shared [pepsi] KEY_DIR (provisioned by pepsi-setup(1)), using
the selectors from the [pepsi] section.
This stage exists so that message construction and DKIM signing are separate
steps. In particular pepsi-stage-bounce(1) builds an unsigned DSN and
points its NEXT_STAGE here, so a generated bounce is signed as its
postmaster’s domain before delivery.
Signing is fail-closed: a message is never advanced unsigned. What happens instead depends on whose fault it is:
No signing domain can be determined (no
SIGNING_DOMAINand no usableFrom:header), or this deployment has no key directory for the domain under[pepsi] KEY_DIR: the message reacheddkim-signfor a domain nobody here signs for. With a BOUNCE_STAGE it is rerouted there with a DSN reason instate.bounce(status5.7.1for a domain without keys,5.6.0for a message without a usableFrom:), split first into one row per recipient so each recipient’sNOTIFYis honoured; the diagnostic names the domain, never the key directory. Without one it is moved to the terminalfailedstate, with the reason instate.last_error. A null-sender message (itself a DSN or an auto-reply) is always failed rather than rerouted: a bounce is never bounced, and failed it stays visible to the operator.The key directory exists but a key cannot be read or used — a rotation left half done, a permission changed, a broken key file: the host is at fault, not the message. The error is retried (see pepsi-dispatch(1)): the message stays
pausedat this stage with the reason instate.last_errorand is retried, after one minute and then at doubling intervals up to an hour, until it has been queued longer than the stage’s MAX_LIFETIME (default120 h); only then is it given up. Failing it at once would bounce every outbound message, and the bounces, which are signed here too, would then be dropped without anybody hearing.
The stage’s own section and [pepsi] are checked when the worker starts: a
configuration that does not parse makes the worker refuse to start (exit
78), and the dispatcher holds the queue until it is fixed.
85.1.8.1.3.1. Body coverage¶
The signature always covers the whole body strictly: any change to the body in
transit invalidates it. Pepsi never emits RFC 6376’s body-length (l=) tag,
and there is no option to make it.
The tag exists so a signature can outlive a mailing list appending a footer, but
no operator can usefully make that trade. mail-auth’s strict parser — which
Pepsi’s own verifier uses, and which every mail-auth/Stalwart deployment uses —
rejects any signature with l > 0 outright rather than merely tolerating
less coverage, so the tag converts a signature that would have passed into one
that fails. The case it exists for is also the case where the list’s rewrite has
already broken SPF, so under a DMARC p=reject policy that DKIM failure is the
difference between delivered and rejected. RFC 6376 §8.2 separately warns that
appended content can replace the original in the reader’s eyes through MIME
restructuring or lax HTML parsing, and that “signers should be extremely wary of
using this tag”.
85.1.8.1.3.2. Header coverage¶
The headers covered by the signature (the h= tag) are the SIGNED_HEADERS
list (default: the common originator/MIME headers). From and Subject are
listed twice so they are over-signed (RFC 6376 §8.15): a header named once
more than it actually occurs is signed one extra (empty) time, so an attacker who
prepends a second From: or Subject: — the headers that change a message’s
apparent origin or meaning — produces an occurrence the signature does not cover,
which breaks verification. Listing any header in SIGNED_HEADERS that is absent
from the message likewise over-signs it (it cannot later be added).
85.1.8.1.4. Configuration¶
The stage is wired and configured through its own [stage-<name>] section
(PROGRAM = pepsi-stage-dkim-sign, a required NEXT_STAGE). Its options —
HEADER_CANONICALIZATION/BODY_CANONICALIZATION (chosen
independently), SIGNATURE_EXPIRATION_DAYS, SIGNED_HEADERS and SIGNING_DOMAIN
— together with the shared [pepsi] key material (KEY_DIR,
DKIM_SELECTOR, DKIM_ALGORITHMS) are documented in pepsi.conf(5).
The hash is always SHA-256; by default both an RSA and an Ed25519 signature are
emitted.
85.1.8.1.5. State¶
Inputs: none from state — the signing domain comes from
SIGNING_DOMAIN or the from_header column, and the body hash from the
loaded message.
Outputs: none added to state. The signatures are prepended to the
headers column; the whole state — including state.dsn — is preserved
unchanged. The state layout is described in pepsi.state(7).
Transitions: on success advances to NEXT_STAGE (the delivery stage),
having prepended the DKIM-Signature header(s). If the signing domain cannot be
determined or has no keys here, the message is rerouted to BOUNCE_STAGE
(one row per recipient, state.bounce set) or, without one or for a
null-sender message, moved to the terminal failed state; if its keys cannot
be used, it is paused for a retry until MAX_LIFETIME (fail-closed; see
above). The stage never finishes a message.
85.1.8.1.6. Commands¶
Run as a worker, the program signs each message and advances it.
85.1.8.1.7. Global Options¶
- -c FILE, –config FILE
Read the configuration from FILE instead of the default search path.
- -L LOGLEVEL, –log LOGLEVEL
Set the logging verbosity (
error,warn,info,debug,trace; defaultinfo).- -h, –help
Print a usage summary and exit.
- -V, –version
Print the version and exit.
85.1.8.1.8. Exit Status¶
The outcome of each message is reported to pepsi-dispatch(1) on the worker’s status line, not as the exit status.
- 0
The worker ran until its standard input was closed.
- 1
A fatal error occurred (unreadable configuration, the database could not be opened, or standard input/output failed). The reason is written to the log.
- 78
The worker refused to start: its section or
[pepsi]does not parse. The dispatcher requeues what it handed over and retries the stage later.
85.1.8.1.9. See Also¶
pepsi-config(1), pepsi-stage-bounce(1), pepsi-stage-relay-to-internet(1), pepsi-stage-relay-to-smarthost(1), pepsi-dispatch(1), pepsi-ingress(1), pepsi.conf(5), pepsi.state(7), pepsi-setup(1)
85.1.8.1.10. Bugs¶
Report bugs to the Pepsi issue tracker.