Documentation
¶
Overview ¶
Package arc verifies Authenticated Received Chain (ARC) headers per RFC 8617.
ARC lets a message's authentication assessment survive intermediaries — mailing lists, forwarders, and other relays that legitimately modify a message and so break its original DKIM signature or SPF alignment. Each participating hop records what it saw by prepending an ARC set of three header fields:
- ARC-Authentication-Results (AAR): the authentication results the hop observed;
- ARC-Message-Signature (AMS): a DKIM-style signature over the message;
- ARC-Seal (AS): a signature over the chain of ARC header fields so far.
A downstream receiver can then cryptographically confirm the whole chain and trust the earliest hop's assessment even though DKIM or SPF no longer pass directly. This package does both sides: Verify checks an existing chain, and Seal adds a new set as a forwarder — what Seal produces, Verify accepts.
Verifying ¶
Verify checks a raw RFC 5322 message and returns a chain-validation status together with a human-readable reason:
cv, reason := arc.Verify(ctx, raw, nil) // nil resolver → system DNS
fmt.Printf("arc=%s (%s)\n", cv, reason)
It validates both the chain's structure — ARC sets numbered contiguously 1..N, each set complete — and its cryptography (RFC 8617 §5.2): the most recent ARC-Message-Signature must verify over the message, and every ARC-Seal must verify over the ARC header chain up to its instance. Signing keys are fetched from DNS at <selector>._domainkey.<domain>; pass a dkim.TXTResolver to override the lookup (for tests or a custom resolver), or nil for system DNS.
Sealing ¶
Seal is the counterpart to Verify: a forwarder adds one ARC set — an ARC-Authentication-Results, ARC-Message-Signature, and ARC-Seal for instance i=N — and returns the message with the set prepended, ready to relay. It records cv= by verifying the chain it is extending: "none" when there is no prior chain, otherwise the "pass"/"fail" Verify reports for the message as received.
res, err := arc.Seal(ctx, raw, arc.SealOptions{
Domain: "example.com",
Selector: "arc",
PrivateKey: key, // *rsa.PrivateKey, e.g. from dkim.ParsePrivateKey
AuthResults: "example.com; spf=pass smtp.mailfrom=a@example.com",
})
// res.Message is raw with the new ARC set prepended; relay it.
The ARC-Message-Signature is structurally a DKIM-Signature and the ARC-Seal a DKIM-style signature over the ARC header chain, both rsa-sha256, so sealing reuses the DKIM signing key and the same go-dkim canonicalization the verifier uses — what Seal produces, Verify (and any conformant RFC 8617 verifier) accepts over the exact transmitted bytes.
Status values ¶
The status is one of three RFC 8617 chain-validation values:
- "pass" — the chain is present and cryptographically intact;
- "fail" — the chain is present but broken: a bad signature, a tampered field, or a structurally invalid chain;
- "none" — the message carries no ARC sets.
Record it in a downstream Authentication-Results header as arc=<status>.
Building on go-dkim ¶
An ARC-Message-Signature is structurally a DKIM-Signature, and an ARC-Seal is a DKIM-style signature over the ARC header fields. This package therefore reuses the canonicalization and signature primitives exported by github.com/rest-mail/go-dkim — its only dependency — rather than reimplementing them, so ARC verification is byte-for-byte consistent with DKIM verification over the same message. ARC uses rsa-sha256: the ARC-Seal is verified with relaxed header canonicalization, and the ARC-Message-Signature is verified exactly as the DKIM-Signature it mirrors, honoring the canonicalization declared in its own c= tag — except that, per RFC 8617 §4.1.2, the AMS is not versioned: any v= tag it carries is not part of it and is ignored rather than checked against the DKIM version rule.
Example ¶
Example verifies an ARC-sealed message. Real ARC sets are added upstream by forwarders and mailing lists; go-arc only verifies them. To keep the round trip self-contained, sealARC below builds one valid ARC set from go-dkim's primitives, and an in-memory resolver serves the matching public key — in production you would pass nil for the resolver and let Verify use system DNS.
package main
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
arc "github.com/rest-mail/go-arc"
"github.com/rest-mail/go-dkim"
)
func main() {
// One keypair signs the ARC set here and answers the key lookup below. Keep
// the private key for signing; publish the public half as the DNS TXT record.
privPEM, pubPEM, err := dkim.GenerateKey(2048)
if err != nil {
panic(err)
}
key, err := dkim.ParsePrivateKey(privPEM)
if err != nil {
panic(err)
}
raw := []byte("From: alice@example.com\r\n" +
"To: bob@example.net\r\n" +
"Subject: hello\r\n" +
"Date: Thu, 23 Jul 2026 10:00:00 +0000\r\n" +
"Message-ID: <1@example.com>\r\n" +
"\r\n" +
"Hello, world!\r\n")
sealed := sealARC(key, "example.com", "arc", raw)
// Serve the public key we just generated from memory so the example needs no
// real DNS. In production, pass nil to use the system resolver.
txt, err := dkim.RecordValue(pubPEM)
if err != nil {
panic(err)
}
resolver := func(_ context.Context, name string) ([]string, error) {
if name == dkim.RecordName("arc", "example.com") {
return []string{txt}, nil
}
return nil, fmt.Errorf("no record for %s", name)
}
cv, reason := arc.Verify(context.Background(), sealed, resolver)
fmt.Printf("%s: %s\n", cv, reason)
}
// sealARC prepends a single valid ARC set (instance 1) to raw, signed with priv
// for d=domain s=selector. It uses only go-dkim's exported primitives, mirroring
// what an ARC sealer at the first hop emits, so arc.Verify sees a genuine chain.
func sealARC(priv *rsa.PrivateKey, domain, selector string, raw []byte) []byte {
headers, body := dkim.SplitMessage(raw)
aar := dkim.Header{
Name: "ARC-Authentication-Results",
Value: " i=1; " + domain + "; spf=pass",
Raw: "ARC-Authentication-Results: i=1; " + domain + "; spf=pass",
}
bh := base64.StdEncoding.EncodeToString(
dkim.HashBytes(crypto.SHA256, []byte(dkim.CanonicalizeBody(body, "relaxed"))))
hTag := "from:to:subject:date:message-id"
amsNoB := fmt.Sprintf("i=1; a=rsa-sha256; c=relaxed/relaxed; d=%s; s=%s; h=%s; bh=%s; b=",
domain, selector, hTag, bh)
amsForSigning := dkim.Header{Name: "ARC-Message-Signature", Value: " " + amsNoB, Raw: "ARC-Message-Signature: " + amsNoB}
amsB := signRSA(priv, dkim.BuildSignedHeaders(hTag, headers, amsForSigning, "relaxed"))
ams := dkim.Header{
Name: "ARC-Message-Signature",
Value: " " + amsNoB + amsB,
Raw: "ARC-Message-Signature: " + amsNoB + amsB,
}
asNoB := fmt.Sprintf("i=1; a=rsa-sha256; d=%s; s=%s; cv=none; b=", domain, selector)
asForSigning := dkim.Header{Name: "ARC-Seal", Value: " " + asNoB, Raw: "ARC-Seal: " + asNoB}
sealBase := dkim.CanonicalizeHeader(aar, "relaxed") + "\r\n" +
dkim.CanonicalizeHeader(ams, "relaxed") + "\r\n" +
dkim.CanonicalizeHeader(asForSigning, "relaxed")
asB := signRSA(priv, sealBase)
as := dkim.Header{Name: "ARC-Seal", Raw: "ARC-Seal: " + asNoB + asB}
return []byte(as.Raw + "\r\n" + ams.Raw + "\r\n" + aar.Raw + "\r\n" + string(raw))
}
func signRSA(priv *rsa.PrivateKey, data string) string {
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, dkim.HashBytes(crypto.SHA256, []byte(data)))
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(sig)
}
Output: pass: ARC chain cryptographically verified (1 set(s))
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Verify ¶
Verify cryptographically verifies the ARC chain (RFC 8617 §5.2) in a raw message: the most recent ARC-Message-Signature must verify over the message (a DKIM-style signature), and every ARC-Seal must verify over the ARC header chain up to its instance. It returns a chain-validation status — "pass", "fail", or "none" — plus a human-readable reason. A nil resolver uses the system DNS resolver.
Header/body canonicalization is shared with dkim.Verify (via the primitives exported by github.com/rest-mail/go-dkim), so ARC verification is consistent with DKIM verification and with any RFC 8617 verifier operating on the same bytes.
Types ¶
type SealOptions ¶ added in v0.2.0
type SealOptions struct {
// Domain is the d= signing domain for the ARC-Message-Signature and
// ARC-Seal (required).
Domain string
// Selector is the s= selector; its key record lives at
// <Selector>._domainkey.<Domain> (required).
Selector string
// PrivateKey is the RSA key that signs the ARC set (required). Parse a PEM
// key with dkim.ParsePrivateKey.
PrivateKey *rsa.PrivateKey
// AuthResults is the authentication-results content recorded in the
// ARC-Authentication-Results field, after the "i=N; " instance prefix that
// Seal adds — e.g. "example.com; spf=pass smtp.mailfrom=a@example.com;
// dkim=pass header.d=example.com". If empty, "<Domain>; none" is recorded.
AuthResults string
// Headers lists the message headers the ARC-Message-Signature covers (its h=
// tag). Empty means from:to:subject:date:message-id. Only headers actually
// present in the message are signed, matching dkim.Sign.
Headers []string
// HeaderCanon is the ARC-Message-Signature header canonicalization,
// "relaxed" (default) or "simple". The ARC-Seal is always relaxed per
// RFC 8617.
HeaderCanon string
// BodyCanon is the ARC-Message-Signature body canonicalization, "relaxed"
// (default) or "simple".
BodyCanon string
// Time is the t= timestamp stamped into both the ARC-Message-Signature and
// the ARC-Seal. Zero means "now" (time.Now().Unix()); set it explicitly for
// reproducible output.
Time int64
// Resolver fetches the DNS TXT keys used to validate the existing ARC chain
// when computing cv= (see Seal). It matches net.Resolver.LookupTXT; nil uses
// the system resolver. It is only consulted when the message already carries
// an ARC chain (i.e. this is not instance 1).
Resolver dkim.TXTResolver
}
SealOptions configures Seal. Domain, Selector, and PrivateKey are required; every other field falls back to a documented default.
type SealResult ¶ added in v0.2.0
type SealResult struct {
// Instance is the ARC instance number (i=) of the new set: 1 more than the
// highest instance already on the message (RFC 8617 §5.1 step 3).
Instance int
// ChainValidation is the cv= value recorded in the ARC-Seal — "none" (no
// prior chain), "pass", or "fail" (RFC 8617 §5.1.1).
ChainValidation string
// AAR, AMS, and AS are the three new header fields, each a complete RFC 5322
// field ("Name: value") with no trailing CRLF: ARC-Authentication-Results,
// ARC-Message-Signature, and ARC-Seal respectively.
AAR string
AMS string
AS string
// Message is rawMessage with the new ARC set prepended in RFC 8617 order
// (ARC-Seal, ARC-Message-Signature, ARC-Authentication-Results, then the
// original message), ready to relay.
Message []byte
}
SealResult is the ARC set Seal produced for instance i=N.
func Seal ¶ added in v0.2.0
func Seal(ctx context.Context, rawMessage []byte, opt SealOptions) (*SealResult, error)
Seal adds one ARC set (RFC 8617 §5.1) to a raw RFC 5322 message and returns it, sealing the message for the next hop. It is the counterpart to Verify: what Seal produces, Verify accepts.
For instance i=N (one past the highest instance already on the message, per RFC 8617 §5.1 step 3) it builds the three ARC header fields:
- ARC-Authentication-Results (AAR): "i=N; " + opt.AuthResults;
- ARC-Message-Signature (AMS): a DKIM-style rsa-sha256 signature over the message headers and body, structurally a DKIM-Signature but tagged with i= and carrying no v=;
- ARC-Seal (AS): an rsa-sha256 signature over the relaxed-canonicalized ARC header chain up to and including this set, carrying cv=.
cv= is the chain-validation status of the chain being extended, computed by verifying it: "none" if the message carries no prior ARC sets, otherwise the "pass"/"fail" that Verify returns for the message as received. Computing cv for i>1 fetches the prior signers' keys via opt.Resolver (nil → system DNS); i=1 needs no lookup.
The AMS and AS share go-dkim's canonicalization and signing primitives (and the very ARC-Seal base builder Verify uses), so a freshly sealed message verifies here — and at any conformant RFC 8617 verifier — over the exact transmitted bytes.
Example ¶
ExampleSeal seals a message the way a forwarder would, then verifies the result with arc.Verify — the round trip a downstream receiver relies on. Unlike Example above (which hand-rolls a set to exercise the verifier), this uses the public arc.Seal API end to end. An in-memory resolver serves the signing key; in production publish the public half as a DNS TXT record and pass nil for the resolver.
package main
import (
"context"
"fmt"
arc "github.com/rest-mail/go-arc"
"github.com/rest-mail/go-dkim"
)
func main() {
privPEM, pubPEM, err := dkim.GenerateKey(2048)
if err != nil {
panic(err)
}
key, err := dkim.ParsePrivateKey(privPEM)
if err != nil {
panic(err)
}
raw := []byte("From: alice@example.com\r\n" +
"To: bob@example.net\r\n" +
"Subject: hello\r\n" +
"Date: Thu, 23 Jul 2026 10:00:00 +0000\r\n" +
"Message-ID: <1@example.com>\r\n" +
"\r\n" +
"Hello, world!\r\n")
txt, err := dkim.RecordValue(pubPEM)
if err != nil {
panic(err)
}
resolver := func(_ context.Context, name string) ([]string, error) {
if name == dkim.RecordName("arc", "example.com") {
return []string{txt}, nil
}
return nil, fmt.Errorf("no record for %s", name)
}
res, err := arc.Seal(context.Background(), raw, arc.SealOptions{
Domain: "example.com",
Selector: "arc",
PrivateKey: key,
AuthResults: "example.com; spf=pass smtp.mailfrom=alice@example.com",
Resolver: resolver,
})
if err != nil {
panic(err)
}
cv, reason := arc.Verify(context.Background(), res.Message, resolver)
fmt.Printf("sealed i=%d cv=%s -> verify %s: %s\n", res.Instance, res.ChainValidation, cv, reason)
}
Output: sealed i=1 cv=none -> verify pass: ARC chain cryptographically verified (1 set(s))