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_DOMAIN and no usable From: header), or this deployment has no key directory for the domain under [pepsi] KEY_DIR: the message reached dkim-sign for a domain nobody here signs for. With a BOUNCE_STAGE it is rerouted there with a DSN reason in state.bounce (status 5.7.1 for a domain without keys, 5.6.0 for a message without a usable From:), split first into one row per recipient so each recipient’s NOTIFY is honoured; the diagnostic names the domain, never the key directory. Without one it is moved to the terminal failed state, with the reason in state.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 paused at this stage with the reason in state.last_error and 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 (default 120 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; default info).

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