ARC (Authenticated Received Chain, RFC 8617) signing and verification for Go. Pure stdlib — no dependencies.
ARC preserves email authentication results across forwarding hops. Each intermediary adds one ARC set — ARC-Authentication-Results (what its receiving edge observed), ARC-Message-Signature (a DKIM-style signature over the message), and ARC-Seal (a signature over every ARC header of every instance so far, making the chain tamper-evident).
This library is the shared ARC engine of the OpenEmail platform: the signer runs in the forwarding path (openemail-smtp-out), the verifier at the receiving edge (openemail-smtp-in). Signer and verifier are two directions of the same computation, kept in one package so they cannot drift.
result, err := arc.Verify(rawMessage, &arc.VerifyOptions{
LookupTXT: arc.DefaultLookupTXT, // inject a stub in tests
})
// result.Pass, result.Instance, result.FailureReasonsA message with no ARC headers passes (nothing to validate). A chain passes only if every instance 1..N is complete, every cv= is semantically valid (none at i=1, pass above, any fail is fatal), and every signature verifies.
authRes, ok := arc.FindAuthenticationResults(rawMessage, "mx.example.com")
if !ok {
// No edge-stamped Authentication-Results — forward without sealing.
}
res, err := arc.Sign(rawMessage, &arc.SignConfig{
Domain: "example.com", // d=: the forwarding platform's domain
Selector: "arc1",
PrivateKey: key, // RSA; DKIM-style TXT record at arc1._domainkey.example.com
AuthResults: authRes, // REQUIRED: the ingress observations
})
// res.Message has the new ARC set prepended; res.Instance, res.CVDesign decisions, all load-bearing:
AuthResultsis required. The AAR must carry what the ADMD's receiving edge actually observed (§5.1.1) — the edge stampsAuthentication-Results, the message carries it to the egress,FindAuthenticationResultsextracts it. A sealer with nothing to report must not seal; fabricated verdicts defeat the mechanism.cv=is computed, never assumed. With prior sets present, the chain is validated (DNS required) and the seal declares the true result. Onfail, the seal covers only the sealer's own set (§5.1.2).- The seal is hand-computed, not borrowed from a DKIM signer. It covers all prior ARC sets plus the new AAR and AMS, relaxed-canonicalized in instance order, and carries exactly the tags §4.1.3 allows:
i, a, b, cv, d, s, t— nobh=, noh=, noc=, nov=. - Everything signed is exactly what is emitted. Headers are folded before hashing and the
b=value is spliced into a frozen layout, so a verifier reconstructing the signed input gets identical bytes. - Refusals over guesses. A chain at instance 50 (
ErrChainLimit) or ARC headers too damaged to number honestly (ErrMalformedChain) mean: forward without sealing.
go test ./... # offline: round-trips, tamper/strip negatives, tag shape
GOARC_LIVE_DNS=1 go test ./... # + cryptographic verification of a real Gmail ARC chainThe suite pins the property naive implementations miss: stripping a prior ARC set must break every later seal. Cross-validation status: the verifier passes Google's real ARC chain (live DNS); chains produced by the signer are accepted by dkimpy's independent arc_verify (cv=pass, all signatures valid).