70.1.4. pepsi-stage-arc

ARC-verify, sign and seal in the Pepsi pipeline

Manual section:

1

70.1.4.1.1. Name

pepsi-stage-arc - the ARC verification and sealing stage of the Pepsi pipeline.

70.1.4.1.2. Synopsis

pepsi-stage-arc [GLOBAL-OPTIONS] worker

70.1.4.1.3. Description

pepsi-stage-arc is a stage program run by pepsi-dispatch(1) as a persistent worker reading message ids on standard input. It implements ARC (RFC 8617): it verifies any existing inbound ARC chain, records the resulting verdict in the row’s state.auth.arc, and — when an ARC signing identity is configured — seals the message as our ADMD by prepending a fresh ARC set: ARC-Authentication-Results (AAR), ARC-Message-Signature (AMS) and ARC-Seal (AS). Sealing only prepends header lines, so existing signatures lower in the message are undisturbed. The row then advances to its NEXT_STAGE. The row’s state — including any RFC 3461 state.dsn parameters — is left untouched for later stages to honour.

This work was previously done inline by pepsi-ingress(1); it is now a stage so ingress no longer holds the ARC signing keys. Ingress verifies SPF, DKIM and DMARC, writes the standalone Authentication-Results header, and records the rendered result text under state.origin.ar_results; this stage adds only the ARC set. The AAR (which, per RFC 8617, should mirror the receiver’s full assessment) is built by feeding that stored fragment straight back in — so SPF/DKIM/DMARC are not re-verified here, and the DNS-heavy work is done exactly once. Only the inbound ARC chain — which ingress does not assess — is verified by this stage, to compute the chain-validation (cv=) value and append its own arc= line to the AAR.

70.1.4.1.4. Who sealed the chain: state.auth.arc_sealers

Besides the verdict, the stage records the d= of every ARC-Seal the message arrived carrying, as a JSON array under state.auth.arc_sealers — lower-cased, stripped of any trailing root dot, de-duplicated, and ordered by instance so the ADMD closest to the originator comes first. Our own ARC_DOMAIN never appears: the list is read from the message before this stage’s own set is rendered, so a seal we added cannot satisfy a rule about intermediaries.

This exists because state.auth.arc cannot answer the question a receiver actually has. RFC 8617 §8.4 is explicit that a valid chain conveys no trustworthiness — it establishes only that the ADMDs named in it handled the message, and deliberately leaves to local policy the question of whether those ADMDs are honest. A receiver that wants to excuse a forwarding hop’s broken SPF or DKIM therefore has to name the hops it accepts, and it needs somewhere to read the hops from. That is this key; the sealer_domain rows of pepsi-stage-check-whitelist(1) are the policy written against it.

The array is written whether or not the chain validated — an unvalidated d= is a claim, and the record of what was claimed is what an operator diagnoses a rejected message with. Anything consuming it must therefore first require state.auth.arc = pass; check-whitelist does, discarding the whole list otherwise, so a chain anybody minted cannot name a trusted sealer.

70.1.4.1.5. Chain validation and cv=

RFC 8617 §5.2 defines three chain-validation outcomes, and the cv= tag of the ARC-Seal this stage adds reports which one applies to the message as it arrived:

cv=none

No ARC chain was present — this ADMD is the first to seal the message.

cv=pass

A chain was present and every set validated.

cv=fail

A chain was present and did not validate.

The distinction matters more than it looks. cv=none is an assertion about provenance, not merely an absence: it tells the next receiver that nothing upstream had already vouched for this message. Emitting it over a chain that was present but broken would launder that chain — the downstream validator would see an apparently pristine i=1 set and no evidence that anything had been tampered with.

Pepsi therefore seals cv=fail for every inbound chain that fails to validate, including the cases where the chain cannot be read at all: an ARC-Seal/ARC-Message-Signature/ARC-Authentication-Results header that does not parse, a chain longer than the 50 sets RFC 8617 permits, and a chain with unequal numbers of the three header types. The new set continues the chain’s instance numbering rather than restarting it at i=1.

The one exception is a chain whose most recent ARC-Seal already says cv=fail. Per RFC 8617 §5.1.1 a failed chain cannot be repaired and that seal is the last one, so this stage adds no set at all and the message is forwarded with the chain as it stands.

The session context the seal needs — the authserv-id, the rendered Authentication-Results fragment, the connecting client IP and the announced HELO/EHLO name — is read from the SMTP-origin metadata ingress recorded under the row’s state.origin; the envelope sender comes from the mail_from column. This is how information is passed from ingress to this stage.

The ARC signing key is the [pepsi] ARC_DOMAIN domain’s DKIM key under KEY_DIR (provisioned by pepsi-setup(1)), signed with the single algorithm named by [pepsi] ARC_ALGORITHM (ARC permits one signature per hop).

ARC applies only to mail we receive and forward: it preserves an upstream ADMD’s authentication across our hop. It must therefore never touch mail we originate — our own authenticated submissions (state.local_origin), which are authenticated as the author domain by pepsi-stage-dkim-sign(1) instead. Sealing such a message would only publish the submission’s own SPF/DKIM/DMARC verdicts (typically an SPF fail for a roaming client not listed in our SPF record) inside the ARC set. The stage therefore skips any state.local_origin message it is handed, advancing it unsealed; this makes the stage safe wherever it is placed. On a host that both receives and submits, place it on the inbound branch only, after the state.local_origin split — so it sees only received mail, ahead of SRS envelope rewriting and any other transformation. On a receive-only host it is naturally the initial stage ([stage-init]).

Sealing is fail-open: if the origin context is missing, the [pepsi] configuration or ARC keys are unavailable, or sealing errors, the message is advanced unsealed with a warning rather than being held up (the ARC verdict is still recorded whenever it could be computed).

70.1.4.1.6. Configuration

The stage is wired and configured through its own [stage-<name>] section (PROGRAM = pepsi-stage-arc, a required NEXT_STAGE). Its options — the DNS settings (DNS_SERVERS, DNS_TIMEOUT) and the ARC-set signature parameters (HEADER_CANONICALIZATION, BODY_CANONICALIZATION, COVER_BODY, SIGNATURE_EXPIRATION_DAYS, SIGNED_HEADERS) — together with the shared [pepsi] signing identity (ARC_DOMAIN, ARC_ALGORITHM, KEY_DIR, DKIM_SELECTOR) are documented in pepsi.conf(5).

70.1.4.1.7. State

Inputs: state.local_origin — when true the message is one of our own submissions and is advanced untouched (no ARC). Otherwise state.origin — the SMTP-origin metadata ingress recorded. The authserv_id is required to reproduce the AAR identity (without it the stage advances unsealed, fail-open); ar_results carries ingress’s rendered Authentication-Results fragment, reused verbatim in the AAR so SPF/DKIM/DMARC need not be re-checked; remote_ip and helo refine the ARC-verification context.

Outputs: the stage overwrites state.auth.arc with the re-evaluated chain verdict (replacing the none ingress seeds there), writes state.auth.arc_sealers (see below) and prepends the ARC set to the headers column; the rest of state — including the other state.auth verdicts and state.dsn — is preserved unchanged. The state layout is described in pepsi.conf(5).

Transitions: always advances to NEXT_STAGE — after prepending the ARC set, after merely recording the verdict in state.auth.arc when no signing identity is configured, or immediately (untouched) for a state.local_origin message, which is never ARC-processed. There is no branch; the stage never pauses, fails, reroutes or finishes.

70.1.4.1.8. Commands

Run as a worker, the program verifies and seals each message and advances it.

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

70.1.4.1.10. Exit Status

0

The message was sealed (or advanced unsealed, fail-open) and moved to its next stage, or the subcommand succeeded.

1

An error occurred (misconfiguration, the message is not in the expected state, or a database error). The reason is written to the log.

70.1.4.1.11. See Also

pepsi-config(1), pepsi-ingress(1), pepsi-stage-srs(1), pepsi-stage-dkim-sign(1), pepsi-dispatch(1), pepsi.conf(5), pepsi.state(7), pepsi-setup(1)

70.1.4.1.12. Bugs

Report bugs to the Pepsi issue tracker.