arc

package module
v0.0.0-...-2cc7f55 Latest Latest
Warning

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

Go to latest
Published: Apr 3, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

README

arc

Build Status codecov Go Report Card Apache V2 License GitHub Release GoDoc

Go implementation of RFC 8617, the Authenticated Received Chain (ARC) protocol.

ARC provides an authenticated "chain of custody" for email messages, allowing each entity that handles a message to see what entities handled it before and what the message's authentication assessment was at each step.

Install

go get github.com/schmidtw/arc

Usage

Validating an ARC chain
import (
    "context"
    "io"
    "github.com/schmidtw/arc"
)

func validateMessage(message io.Reader) error {
    v, err := arc.NewValidator() // uses net.DefaultResolver
    if err != nil {
        return err
    }
    present, err := v.Validate(context.Background(), message)
    if err != nil {
        return err // chain validation failed
    }
    if !present {
        // No ARC headers present
    }
    // ARC chain validated successfully
    return nil
}
Signing a message
import (
    "context"
    "crypto"
    "github.com/schmidtw/arc"
)

func signMessage(message []byte, privateKey crypto.Signer) ([]byte, error) {
    signer, err := arc.NewSigner(privateKey, "sel._domainkey.example.org")
    if err != nil {
        return nil, err
    }

    return signer.SignBytes(context.Background(), message, "spf=pass; dkim=pass")
}

Sign validates any existing ARC chain before adding a new set. If validation succeeds the new set is marked as passing; if validation fails it is marked as failing. Signing is refused if the most recent set was already marked as failing.

Custom Resolver

Both NewValidator and NewSigner accept WithResolver to supply a custom DNS resolver. This is useful for testing or environments where net.DefaultResolver is not appropriate.

v, err := arc.NewValidator(arc.WithResolver(myResolver))
if err != nil {
    return err
}

s, err := arc.NewSigner(key, domainKey,
    arc.WithValidator(v),
    arc.WithResolver(myResolver))
if err != nil {
    return err
}

Any type that implements LookupTXT(ctx context.Context, name string) ([]string, error) satisfies the Resolver interface. The standard library's *net.Resolver works out of the box.

Supported Algorithms

  • RSA-SHA256 (rsa-sha256)
  • Ed25519-SHA256 (ed25519-sha256)

License

Apache-2.0

Documentation

Overview

Package arc implements RFC 8617, the Authenticated Received Chain (ARC) protocol.

ARC provides an authenticated "chain of custody" for email messages, allowing each entity that handles the message to see what entities handled it before and what the message's authentication assessment was at each step.

Validating an ARC Chain

To validate an ARC chain on an incoming message:

import (
	"context"
	"io"
	"github.com/schmidtw/arc"
)

func validateMessage(message io.Reader) error {
	v, err := arc.NewValidator() // uses net.DefaultResolver
	if err != nil {
		return err
	}
	present, err := v.Validate(context.Background(), message)
	if err != nil {
		return err // chain validation failed
	}
	if !present {
		// No ARC headers present
	}
	// ARC chain validated successfully
	return nil
}

Signing a Message (Creating a New ARC Set)

To add an ARC Set to a message:

import (
	"context"
	"crypto"
	"github.com/schmidtw/arc"
)

func signMessage(message []byte, privateKey crypto.Signer) ([]byte, error) {
	signer, err := arc.NewSigner(privateKey, "sel._domainkey.example.org")
	if err != nil {
		return nil, err
	}

	return signer.SignBytes(context.Background(), message, "spf=pass; dkim=pass")
}

The Sign method validates any existing ARC chain before adding a new set. If validation succeeds, the new set is marked as passing. If validation fails, the new set is marked as failing. Signing is refused if the most recent set in the chain was already marked as failing.

To customize validation behavior, provide a validator via WithValidator:

validator, err := arc.NewValidator(arc.WithMinRSAKeyBits(2048))
if err != nil {
	return nil, err
}
signer, err := arc.NewSigner(privateKey, "sel._domainkey.example.org",
	arc.WithValidator(validator))
if err != nil {
	return nil, err
}

Index

Constants

This section is empty.

Variables

View Source
var ErrInvalidSignature = errors.New("invalid signature")

ErrInvalidSignature is returned when a cryptographic signature verification fails during ARC chain validation. Use errors.Is to check for this error.

View Source
var ErrRSAKeyTooSmall = errors.New("RSA key size is too small")

ErrRSAKeyTooSmall is returned when an RSA key is smaller than the configured minimum size during validation or signing. Use errors.Is to check for this error.

Functions

This section is empty.

Types

type HeaderField

type HeaderField string

HeaderField identifies a message header field for inclusion in the ARC-Message-Signature. Standard values are provided as constants. Custom headers can be used by converting a string: HeaderField("X-My-Header").

const (
	HeaderFrom                    HeaderField = "From"
	HeaderTo                      HeaderField = "To"
	HeaderCc                      HeaderField = "Cc"
	HeaderSubject                 HeaderField = "Subject"
	HeaderDate                    HeaderField = "Date"
	HeaderReplyTo                 HeaderField = "Reply-To"
	HeaderInReplyTo               HeaderField = "In-Reply-To"
	HeaderReferences              HeaderField = "References"
	HeaderMessageID               HeaderField = "Message-ID"
	HeaderMIMEVersion             HeaderField = "MIME-Version"
	HeaderContentType             HeaderField = "Content-Type"
	HeaderContentTransferEncoding HeaderField = "Content-Transfer-Encoding"
	HeaderResentDate              HeaderField = "Resent-Date"
	HeaderResentFrom              HeaderField = "Resent-From"
	HeaderResentTo                HeaderField = "Resent-To"
	HeaderResentCc                HeaderField = "Resent-Cc"
	HeaderListID                  HeaderField = "List-Id"
	HeaderListHelp                HeaderField = "List-Help"
	HeaderListUnsubscribe         HeaderField = "List-Unsubscribe"
	HeaderListSubscribe           HeaderField = "List-Subscribe"
	HeaderListPost                HeaderField = "List-Post"
	HeaderListOwner               HeaderField = "List-Owner"
	HeaderListArchive             HeaderField = "List-Archive"
	HeaderDKIMSignature           HeaderField = "DKIM-Signature"
)

Standard header fields recommended for signing.

const (
	// ARC and authentication headers must not be included in signatures.
	HeaderArcSeal                  HeaderField = "ARC-Seal"
	HeaderArcMessageSignature      HeaderField = "ARC-Message-Signature"
	HeaderArcAuthenticationResults HeaderField = "ARC-Authentication-Results"
	HeaderAuthenticationResults    HeaderField = "Authentication-Results"

	// Headers commonly modified or removed in transit should not be signed.
	HeaderReturnPath HeaderField = "Return-Path"
	HeaderReceived   HeaderField = "Received"
	HeaderComments   HeaderField = "Comments"
	HeaderKeywords   HeaderField = "Keywords"
)

Headers that must not or should not be signed.

type Option

type Option interface {
	SignerOption
	ValidatorOption
}

Option is an option that can be passed to both NewValidator and NewSigner.

func WithMinRSAKeyBits

func WithMinRSAKeyBits(bits int) Option

WithMinRSAKeyBits sets the minimum accepted RSA key size in bits. This option can be passed to both NewValidator and NewSigner.

For validators, this controls the minimum key size accepted when validating other parties' signatures. Default is 1024 bits per RFC 8301 (which updates RFC 6376 Section 3.3.3): verifiers MUST accept 1024-bit to 4096-bit keys.

For signers, this controls the minimum key size for the signer's own private key. Default is 2048 bits per RFC 8301, which states that signers SHOULD use at least 2048 bits.

Values less than 1024 result in an error.

See https://www.rfc-editor.org/rfc/rfc8301 and https://www.rfc-editor.org/rfc/rfc6376#section-3.3.3 for details.

func WithResolver

func WithResolver(r Resolver) Option

WithResolver sets a custom Resolver for DNS TXT record lookups. If not set, net.DefaultResolver is used. This option can be passed to both NewValidator and NewSigner.

type Resolver

type Resolver interface {
	LookupTXT(ctx context.Context, name string) ([]string, error)
}

Resolver looks up DNS TXT records. The standard library's *net.Resolver satisfies this interface.

type Signer

type Signer struct {
	// contains filtered or unexported fields
}

Signer adds new ARC Sets to email messages.

func NewSigner

func NewSigner(key crypto.Signer, domainKey string, opts ...SignerOption) (*Signer, error)

NewSigner creates a new Signer with the given key and domain key FQDN.

The key is the private key used for signing. The signing algorithm is inferred from the key type (RSA or Ed25519).

The domainKey is the DNS domain key FQDN where verifiers look up the corresponding public key:

<selector>._domainkey.<domain>

For example, "2024._domainkey.example.com".

A validator is used to validate existing ARC chains before adding a new set. This determines whether the new ARC-Seal will have cv=pass or cv=fail. If no validator is provided via WithValidator, a default validator with standard settings is created.

By default, the default signed headers are used, which include common message headers (From, To, Subject, Date, etc.). Use WithSignedHeaders to override this set. If no authentication server ID is set via WithAuthServID, it defaults to the domain. If no resolver is set, net.DefaultResolver is used.

The maximum number of ARC Sets is fixed at 50 per RFC 8617. The RFC defines instance values (i=) as ranging from 1 to 50, making this an absolute limit in the protocol specification.

func (*Signer) Sign

func (s *Signer) Sign(ctx context.Context, message io.Reader, authResults string) ([]byte, error)

Sign creates a new ARC Set and prepends it to the message. The message should be a raw email message (RFC 5322 format). It returns the message with the new ARC headers prepended. The authResults parameter is the authentication results string for the ARC-Authentication-Results header (e.g., "spf=pass; dkim=pass").

func (*Signer) SignBytes

func (s *Signer) SignBytes(ctx context.Context, message []byte, authResults string) ([]byte, error)

SignBytes creates a new ARC Set and prepends it to the message bytes.

type SignerOption

type SignerOption interface {
	// contains filtered or unexported methods
}

SignerOption configures a Signer.

func WithAuthServID

func WithAuthServID(id string) SignerOption

WithAuthServID sets the authentication server identifier for the ARC-Authentication-Results header. This identifies the organization that performed authentication checks on the message. If not set, defaults to the signing domain.

See https://www.rfc-editor.org/rfc/rfc8601#section-2.5 for guidance on choosing an appropriate value.

func WithSignedHeaders

func WithSignedHeaders(headers ...HeaderField) SignerOption

WithSignedHeaders sets the message header fields whose values will be covered by the ARC-Message-Signature. Use the HeaderField constants for standard fields, or convert custom header names: HeaderField("X-Custom-Header").

func WithTimestamp

func WithTimestamp(ts time.Time) SignerOption

WithTimestamp sets a fixed signing timestamp. If not set or zero, the current time is used at signing time.

func WithValidator

func WithValidator(v *Validator) SignerOption

WithValidator sets a custom Validator for the signer to use when validating existing ARC chains before adding a new set.

If not provided, a default validator is created with:

You can create a custom validator to control validation behavior:

v, err := arc.NewValidator(
    arc.WithMinRSAKeyBits(2048),
    arc.WithResolver(customResolver),
)
if err != nil {
    return err
}
signer, err := arc.NewSigner(key, domainKey, arc.WithValidator(v))
if err != nil {
    return err
}

type Validator

type Validator struct {
	// contains filtered or unexported fields
}

Validator validates ARC chains on email messages.

func NewValidator

func NewValidator(opts ...ValidatorOption) (*Validator, error)

NewValidator creates a new Validator. If no Resolver is provided via WithResolver, net.DefaultResolver is used. By default, the minimum accepted RSA key size is 1024 bits. Use WithMinRSAKeyBits to override.

The DNS key cache is bounded by default to 1000 entries using LRU eviction. Use WithMaxCacheSize to adjust the limit or disable caching.

The maximum number of ARC Sets is fixed at 50 per RFC 8617. The RFC defines instance values (i=) as ranging from 1 to 50, making this an absolute limit in the protocol specification.

func (*Validator) Validate

func (v *Validator) Validate(ctx context.Context, message io.Reader) (bool, error)

Validate validates the ARC chain on the given message. It returns whether an ARC chain was present (true) or absent (false). If the chain is present but invalid, it returns true and a non-nil error describing the failure.

func (*Validator) ValidateBytes

func (v *Validator) ValidateBytes(ctx context.Context, message []byte) (bool, error)

ValidateBytes validates the ARC chain on the given raw message bytes.

type ValidatorOption

type ValidatorOption interface {
	// contains filtered or unexported methods
}

ValidatorOption configures a Validator.

func WithMaxCacheSize

func WithMaxCacheSize(size int) ValidatorOption

WithMaxCacheSize sets the maximum number of parsed DNS public keys to cache. This option can be passed to NewValidator.

The cache stores parsed key records and built verifier functions. DNS lookups are always performed to fetch the current TXT record; the cache avoids the expensive parsing and cryptographic operations when the DNS record content hasn't changed. This provides content-based cache invalidation - if a key is rotated, the cache detects the change and rebuilds the verifier.

The cache uses LRU (Least Recently Used) eviction when the limit is reached.

Accepted values:

  • 0: Disables caching (always parses and builds verifiers from DNS records)
  • Positive integers: Cache up to that many entries with LRU eviction
  • -1 or any negative value: Unlimited cache size (all negative values are normalized to -1)

Warning: Unlimited cache (-1) is not recommended for long-running services as it can lead to unbounded memory growth.

Default is 1000 entries, which should be sufficient for most use cases while preventing unbounded memory growth in long-running validators.

Note: The cache does not respect DNS TTL values because Go's net.Resolver does not expose TTL information from DNS responses. Cached entries are evicted only by LRU policy or when the DNS record content changes.

Jump to

Keyboard shortcuts

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