arc

package module
v0.2.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 25, 2026 License: MIT Imports: 12 Imported by: 0

README

go-arc

CI Go Reference Go Report Card

Authenticated Received Chain (RFC 8617) sealing and verification for Go.

About

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, ARC-Message-Signature, and ARC-Seal. 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 of ARC over the raw RFC 5322 message bytes — never a parsed or reconstructed representation. arc.Verify checks an existing chain against exactly what was transmitted; arc.Seal adds a new ARC set as a forwarder would, and what Seal produces Verify accepts.

Features

  • Sealing (arc.Seal): add an ARC set — ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal — for instance i=N, with cv= computed by verifying the chain being extended (none/pass/fail). Returns the message with the set prepended, ready to relay.
  • Verifying (arc.Verify): full RFC 8617 §5.2 chain verification — the most recent ARC-Message-Signature over the message, plus every ARC-Seal over the ARC header chain up to its instance.
  • Structural validation: ARC sets must be numbered contiguously 1..N, each set complete.
  • Returns an RFC 8617 chain-validation status — pass, fail, or none — with a human-readable reason.
  • Operates on raw message bytes, the same bytes DKIM signs and verifies.
  • Pluggable DNS resolver (dkim.TXTResolver) for tests and custom lookups; nil uses the system resolver.
  • Built on github.com/rest-mail/go-dkim — its only dependency — so ARC shares DKIM's exact canonicalization and crypto path (rsa-sha256, relaxed canonicalization); sealing reuses the DKIM key.

Install

go get github.com/rest-mail/go-arc

Quickstart

Seal a message as a forwarder would, then verify the result — the round trip a downstream receiver relies on. In production you would publish the public key as a DNS TXT record and pass nil for the resolver (system DNS); here an in-memory resolver serves the key so the example is self-contained.

package main

import (
	"context"
	"fmt"

	arc "github.com/rest-mail/go-arc"
	"github.com/rest-mail/go-dkim"
)

func main() {
	// Keep the private key for signing; publish the public half as the DNS 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")

	// Serve the public key from memory so the round trip needs no real DNS.
	// In production, pass nil to use the system resolver.
	txt, _ := dkim.RecordValue(pubPEM)
	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)
	}

	// Seal: add an ARC set as this hop. cv= is computed from the incoming chain
	// (none here — no prior sets).
	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)
	}

	// Verify what we just sealed.
	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)
	// Prints: sealed i=1 cv=none -> verify pass: ARC chain cryptographically verified (1 set(s))
}

Sealing

arc.Seal adds one ARC set for the next hop:

func Seal(ctx context.Context, rawMessage []byte, opt SealOptions) (*SealResult, error)

SealOptions takes the signing identity — Domain, Selector, and PrivateKey (a *rsa.PrivateKey, e.g. from dkim.ParsePrivateKey) — plus the AuthResults to record and optional canonicalization / Headers / Time / Resolver settings. It:

  • picks the next instance i=N from the ARC sets already in the message;
  • computes cv= by verifying the chain it is extending — none with no prior chain, else the pass/fail Verify reports (this is the only time Seal needs the resolver / DNS);
  • builds the three header fields and returns a *SealResult whose Message is rawMessage with the new set prepended, ready to relay (the individual AAR, AMS, and AS fields and the Instance / ChainValidation are exposed too).

The signing key is reused from DKIM: an ARC-Message-Signature is a DKIM-Signature and an ARC-Seal a DKIM-style signature over the ARC header chain, both rsa-sha256.

Verifying

arc.Verify is the whole public surface:

func Verify(ctx context.Context, rawMessage []byte, resolver dkim.TXTResolver) (cv, reason string)

It returns a chain-validation status ("pass", "fail", or "none") and a human-readable reason. Feed the status into a downstream Authentication-Results header as arc=<cv>. Pass nil for the resolver to use system DNS, or inject a dkim.TXTResolver (its signature matches net.Resolver.LookupTXT) in tests.

An ARC-Message-Signature is structurally a DKIM-Signature and an ARC-Seal is a DKIM-style signature over the ARC header fields, so this package reuses the canonicalization and signature primitives exported by go-dkim rather than reimplementing them — ARC verification is therefore byte-for-byte consistent with DKIM verification over the same message.

Documentation

Full API reference: pkg.go.dev/github.com/rest-mail/go-arc.

Changelog

Recent releases (see CHANGELOG.md for the full history):

  • v0.2.3 — Verify now requires the ARC-Message-Signature to sign From (RFC 8617 §5.1.1); an AMS whose h= omits From is treated as cv=fail.
  • v0.2.2 — restore ARC-Message-Signature verification via go-dkim v0.2.1's policy-free primitive (fixing the regression from DKIM's v= requirement); RFC 8617 verify/seal fixes: 50-set cap, cv=fail seal scope, reject seal h=, require seal s=/d=, instance-from-highest, strict instance/tag parsing.
  • v0.2.1 — validate the ARC-Seal cv= chain, reject repeated ARC fields, ignore an unexpected AMS v=; go-dkim v0.1.2.
  • v0.2.0 — see the release notes.
  • v0.1.1 — see the release notes.
  • v0.1.0 — initial release: ARC sealing and verification (RFC 8617).

License

MIT © 2026 rest-mail

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

func Verify(ctx context.Context, rawMessage []byte, resolver dkim.TXTResolver) (string, string)

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

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL