#17: implement DANE TLSA, MTA-STS policy resolution, ARC sealing and mail authentication
This commit is contained in:
1 parent
df8b809683
commit
bd0af98076
22 files changed
+3296
No files matched your search
@@ -0,0 +1,147 @@
|
||||
// Package arc implements Authenticated Received Chain (RFC 8617).
|
||||
//
|
||||
// ARC exists for the case DKIM cannot cover: a mailing list or forwarder that
|
||||
// legitimately modifies a message breaks its DKIM signature, and the receiving
|
||||
// domain then sees a DMARC failure for mail that was authentic when it was
|
||||
// sent. An ARC chain records each intermediary's own authentication verdict
|
||||
// and seals it, so the final receiver can see that the message authenticated
|
||||
// correctly at the first hop (PLAN.md section 5).
|
||||
//
|
||||
// Nothing in the Go ecosystem provides ARC, so the signature construction is
|
||||
// implemented here on top of the same relaxed canonicalization DKIM uses
|
||||
// (RFC 6376 section 3.4.2), which RFC 8617 mandates for both ARC signatures.
|
||||
package arc
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Header names defined by RFC 8617 section 4.1.
|
||||
const (
|
||||
HeaderAuthenticationResults = "ARC-Authentication-Results"
|
||||
HeaderMessageSignature = "ARC-Message-Signature"
|
||||
HeaderSeal = "ARC-Seal"
|
||||
)
|
||||
|
||||
// header is one parsed header field, kept in the order it appeared.
|
||||
type header struct {
|
||||
name string
|
||||
value string
|
||||
raw string
|
||||
}
|
||||
|
||||
// canonicalizeHeaderRelaxed renders a header field the way RFC 6376 section
|
||||
// 3.4.2 prescribes: lowercase name, whitespace runs folded to a single space,
|
||||
// no space around the colon, no trailing whitespace.
|
||||
func canonicalizeHeaderRelaxed(name, value string) string {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
|
||||
// Unfold: a continuation line is part of the same field value.
|
||||
value = strings.ReplaceAll(value, "\r\n", "")
|
||||
value = strings.ReplaceAll(value, "\n", "")
|
||||
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
return name + ":" + value
|
||||
}
|
||||
|
||||
// canonicalizeBodyRelaxed applies RFC 6376 section 3.4.4: trailing whitespace
|
||||
// removed from each line, internal whitespace runs collapsed, trailing empty
|
||||
// lines removed, and a single CRLF kept when the body is not empty.
|
||||
func canonicalizeBodyRelaxed(body string) string {
|
||||
body = strings.ReplaceAll(body, "\r\n", "\n")
|
||||
lines := strings.Split(body, "\n")
|
||||
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimRight(strings.Join(strings.Fields(line), " "), " \t")
|
||||
}
|
||||
|
||||
// Drop trailing empty lines.
|
||||
end := len(lines)
|
||||
for end > 0 && lines[end-1] == "" {
|
||||
end--
|
||||
}
|
||||
lines = lines[:end]
|
||||
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
// bodyHash is the "bh=" tag: the base64 SHA-256 of the canonicalized body.
|
||||
func bodyHash(body string) string {
|
||||
sum := sha256.Sum256([]byte(canonicalizeBodyRelaxed(body)))
|
||||
return base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// splitMessage separates the header block from the body, keeping the headers
|
||||
// in order and with their continuation lines joined.
|
||||
func splitMessage(message string) (headers []header, body string) {
|
||||
normalized := strings.ReplaceAll(message, "\r\n", "\n")
|
||||
|
||||
headerBlock, body, found := strings.Cut(normalized, "\n\n")
|
||||
if !found {
|
||||
headerBlock = normalized
|
||||
body = ""
|
||||
}
|
||||
|
||||
var current *header
|
||||
for _, line := range strings.Split(headerBlock, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// A line starting with whitespace continues the previous field.
|
||||
if (line[0] == ' ' || line[0] == '\t') && current != nil {
|
||||
current.value += " " + strings.TrimSpace(line)
|
||||
current.raw += "\r\n" + line
|
||||
continue
|
||||
}
|
||||
|
||||
name, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
headers = append(headers, header{
|
||||
name: strings.TrimSpace(name),
|
||||
value: strings.TrimSpace(value),
|
||||
raw: line,
|
||||
})
|
||||
current = &headers[len(headers)-1]
|
||||
}
|
||||
|
||||
return headers, body
|
||||
}
|
||||
|
||||
// sign produces the base64 signature over data for either supported key type.
|
||||
func sign(signer crypto.Signer, data []byte) (string, error) {
|
||||
// RFC 8617 section 4.1.2 allows rsa-sha256 and, following RFC 8463,
|
||||
// ed25519-sha256. Ed25519 signs the message itself; RSA signs its digest.
|
||||
if _, isEd25519 := signer.Public().(ed25519.PublicKey); isEd25519 {
|
||||
signature, err := signer.Sign(rand.Reader, data, crypto.Hash(0))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ed25519 sign: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
digest := sha256.Sum256(data)
|
||||
signature, err := signer.Sign(rand.Reader, digest[:], crypto.SHA256)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rsa sign: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
// algorithmFor names the algorithm in the "a=" tag.
|
||||
func algorithmFor(signer crypto.Signer) string {
|
||||
if _, isEd25519 := signer.Public().(ed25519.PublicKey); isEd25519 {
|
||||
return "ed25519-sha256"
|
||||
}
|
||||
return "rsa-sha256"
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package arc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// maxChainInstance caps the chain length. RFC 8617 section 5.1.1 fixes the
|
||||
// maximum instance number at 50; beyond it the chain must be treated as
|
||||
// failed rather than extended.
|
||||
const maxChainInstance = 50
|
||||
|
||||
// signedHeaders are the fields the ARC-Message-Signature covers. It mirrors
|
||||
// the DKIM set: headers a forwarder is likely to add later stay out, or the
|
||||
// signature would break for the very case ARC exists to handle.
|
||||
var signedHeaders = []string{
|
||||
"from", "to", "cc", "subject", "date", "message-id",
|
||||
"mime-version", "content-type", "content-transfer-encoding",
|
||||
"in-reply-to", "references",
|
||||
}
|
||||
|
||||
// ChainValidation is the "cv=" tag: the state of the chain as it arrived.
|
||||
type ChainValidation string
|
||||
|
||||
const (
|
||||
// ChainNone means no ARC chain was present, so this hop starts one.
|
||||
ChainNone ChainValidation = "none"
|
||||
// ChainPass means the chain arrived intact.
|
||||
ChainPass ChainValidation = "pass"
|
||||
// ChainFail means the chain is broken. RFC 8617 section 5.1.2 forbids
|
||||
// extending a failed chain, so sealing stops here.
|
||||
ChainFail ChainValidation = "fail"
|
||||
)
|
||||
|
||||
// Sealer adds an ARC set to a message being forwarded.
|
||||
type Sealer struct {
|
||||
// domain and selector identify the key, and are the same identity DKIM
|
||||
// signs with: an ARC seal is verified through the same DNS record.
|
||||
domain string
|
||||
selector string
|
||||
signer crypto.Signer
|
||||
// authServID names this server in the ARC-Authentication-Results header.
|
||||
authServID string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewSealer(domain, selector, authServID string, signer crypto.Signer) *Sealer {
|
||||
if authServID == "" {
|
||||
authServID = domain
|
||||
}
|
||||
return &Sealer{
|
||||
domain: domain,
|
||||
selector: selector,
|
||||
signer: signer,
|
||||
authServID: authServID,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// WithClock fixes the timestamp, so a test can assert on exact output.
|
||||
func (s *Sealer) WithClock(now func() time.Time) *Sealer {
|
||||
s.now = now
|
||||
return s
|
||||
}
|
||||
|
||||
// Seal prepends one ARC set - ARC-Authentication-Results, then
|
||||
// ARC-Message-Signature, then ARC-Seal - to the message.
|
||||
//
|
||||
// The order matters and is prescribed by RFC 8617 section 5.1.1: the seal is
|
||||
// computed last, over the other two, and is placed above them.
|
||||
func (s *Sealer) Seal(_ context.Context, message []byte, authResult port.InboundAuthResult) ([]byte, error) {
|
||||
text := string(message)
|
||||
headers, body := splitMessage(text)
|
||||
|
||||
instance, chainState := s.inspectChain(headers)
|
||||
if chainState == ChainFail {
|
||||
// Extending a failed chain is forbidden; the message is forwarded as
|
||||
// it is so the receiver can see the break for itself.
|
||||
return message, nil
|
||||
}
|
||||
if instance > maxChainInstance {
|
||||
return message, nil
|
||||
}
|
||||
|
||||
timestamp := s.now().Unix()
|
||||
|
||||
// 1. ARC-Authentication-Results records what this hop concluded.
|
||||
aar := fmt.Sprintf("i=%d; %s", instance, s.formatAuthResults(authResult))
|
||||
|
||||
// 2. ARC-Message-Signature covers the message, exactly as a DKIM
|
||||
// signature would, but never covers the ARC-Seal headers.
|
||||
ams, err := s.buildMessageSignature(instance, timestamp, headers, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. ARC-Seal covers the whole chain including the two headers above.
|
||||
seal, err := s.buildSeal(instance, timestamp, chainState, headers, aar, ams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
out.WriteString(HeaderSeal + ": " + seal + "\r\n")
|
||||
out.WriteString(HeaderMessageSignature + ": " + ams + "\r\n")
|
||||
out.WriteString(HeaderAuthenticationResults + ": " + aar + "\r\n")
|
||||
out.WriteString(text)
|
||||
|
||||
return []byte(out.String()), nil
|
||||
}
|
||||
|
||||
// inspectChain finds the instance number this hop should use and the state of
|
||||
// the chain that arrived.
|
||||
func (s *Sealer) inspectChain(headers []header) (instance int, state ChainValidation) {
|
||||
highest := 0
|
||||
previousCV := ""
|
||||
|
||||
for _, h := range headers {
|
||||
if !strings.EqualFold(h.name, HeaderSeal) {
|
||||
continue
|
||||
}
|
||||
tags := parseTags(h.value)
|
||||
if i, err := strconv.Atoi(tags["i"]); err == nil && i > highest {
|
||||
highest = i
|
||||
previousCV = tags["cv"]
|
||||
}
|
||||
}
|
||||
|
||||
if highest == 0 {
|
||||
// No chain yet: this hop is instance 1 and reports "none", which is
|
||||
// what RFC 8617 section 5.1.1 requires of the first sealer.
|
||||
return 1, ChainNone
|
||||
}
|
||||
if previousCV == string(ChainFail) {
|
||||
return highest + 1, ChainFail
|
||||
}
|
||||
return highest + 1, ChainPass
|
||||
}
|
||||
|
||||
// buildMessageSignature produces the AMS value. It is a DKIM signature in all
|
||||
// but name, so the tag set and canonicalization match RFC 6376.
|
||||
func (s *Sealer) buildMessageSignature(instance int, timestamp int64, headers []header, body string) (string, error) {
|
||||
present := signedHeaderList(headers)
|
||||
|
||||
unsigned := fmt.Sprintf(
|
||||
"i=%d; a=%s; c=relaxed/relaxed; d=%s; s=%s; t=%d; h=%s; bh=%s; b=",
|
||||
instance, algorithmFor(s.signer), s.domain, s.selector, timestamp,
|
||||
strings.Join(present, ":"), bodyHash(body),
|
||||
)
|
||||
|
||||
material := canonicalHeaderBlock(headers, present)
|
||||
// The signature covers its own header with an empty "b=" tag
|
||||
// (RFC 6376 section 3.7).
|
||||
material += canonicalizeHeaderRelaxed(HeaderMessageSignature, unsigned)
|
||||
|
||||
signature, err := sign(s.signer, []byte(material))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return unsigned + signature, nil
|
||||
}
|
||||
|
||||
// buildSeal produces the AS value. Unlike the message signature it covers no
|
||||
// body and no message headers: only the ARC set headers, in instance order
|
||||
// (RFC 8617 section 5.1.1).
|
||||
func (s *Sealer) buildSeal(instance int, timestamp int64, state ChainValidation, headers []header, aar, ams string) (string, error) {
|
||||
unsigned := fmt.Sprintf(
|
||||
"i=%d; a=%s; cv=%s; d=%s; s=%s; t=%d; b=",
|
||||
instance, algorithmFor(s.signer), state, s.domain, s.selector, timestamp,
|
||||
)
|
||||
|
||||
var material strings.Builder
|
||||
// Every previous ARC set, oldest first.
|
||||
for i := 1; i < instance; i++ {
|
||||
for _, name := range []string{HeaderAuthenticationResults, HeaderMessageSignature, HeaderSeal} {
|
||||
if value, ok := findInstanceHeader(headers, name, i); ok {
|
||||
material.WriteString(canonicalizeHeaderRelaxed(name, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Then this hop's own set, with the seal itself carrying an empty "b=".
|
||||
material.WriteString(canonicalizeHeaderRelaxed(HeaderAuthenticationResults, aar))
|
||||
material.WriteString(canonicalizeHeaderRelaxed(HeaderMessageSignature, ams))
|
||||
material.WriteString(canonicalizeHeaderRelaxed(HeaderSeal, unsigned))
|
||||
|
||||
signature, err := sign(s.signer, []byte(material.String()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return unsigned + signature, nil
|
||||
}
|
||||
|
||||
// formatAuthResults renders this hop's verdicts for the AAR header.
|
||||
func (s *Sealer) formatAuthResults(result port.InboundAuthResult) string {
|
||||
if result.AuthenticationResults != "" {
|
||||
return result.AuthenticationResults
|
||||
}
|
||||
return fmt.Sprintf("%s; spf=%s; dkim=%s; dmarc=%s",
|
||||
s.authServID, orNone(result.SPF), orNone(result.DKIM), orNone(result.DMARC))
|
||||
}
|
||||
|
||||
func orNone(value string) string {
|
||||
if value == "" {
|
||||
return "none"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// signedHeaderList keeps only the headers actually present, in the order the
|
||||
// "h=" tag will list them.
|
||||
func signedHeaderList(headers []header) []string {
|
||||
var present []string
|
||||
for _, candidate := range signedHeaders {
|
||||
for _, h := range headers {
|
||||
if strings.EqualFold(h.name, candidate) {
|
||||
present = append(present, candidate)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return present
|
||||
}
|
||||
|
||||
// canonicalHeaderBlock renders the signed headers in "h=" order.
|
||||
func canonicalHeaderBlock(headers []header, names []string) string {
|
||||
var sb strings.Builder
|
||||
for _, name := range names {
|
||||
for _, h := range headers {
|
||||
if strings.EqualFold(h.name, name) {
|
||||
sb.WriteString(canonicalizeHeaderRelaxed(h.name, h.value))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// findInstanceHeader locates the ARC header of a given name and instance.
|
||||
func findInstanceHeader(headers []header, name string, instance int) (string, bool) {
|
||||
for _, h := range headers {
|
||||
if !strings.EqualFold(h.name, name) {
|
||||
continue
|
||||
}
|
||||
if parseTags(h.value)["i"] == strconv.Itoa(instance) {
|
||||
return h.value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// parseTags reads the "tag=value; tag=value" form ARC and DKIM headers use.
|
||||
func parseTags(value string) map[string]string {
|
||||
tags := map[string]string{}
|
||||
for _, part := range strings.Split(value, ";") {
|
||||
key, val, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(val)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package arc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/infrastructure/dkim"
|
||||
)
|
||||
|
||||
const forwardedMessage = "From: author@origin.test\r\n" +
|
||||
"To: list@forwarder.test\r\n" +
|
||||
"Subject: hello\r\n" +
|
||||
"Date: Mon, 20 Jul 2026 10:00:00 +0000\r\n" +
|
||||
"Message-ID: <m1@origin.test>\r\n" +
|
||||
"\r\n" +
|
||||
"the body\r\n"
|
||||
|
||||
// keyring resolves the public keys of every sealing domain in a test.
|
||||
type keyring map[string]crypto.PublicKey
|
||||
|
||||
func (k keyring) lookup(domain, selector string) (crypto.PublicKey, error) {
|
||||
key, ok := k[domain+"/"+selector]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no key for %s/%s", domain, selector)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func newSealer(t *testing.T, domain, algorithm string, ring keyring) *Sealer {
|
||||
t.Helper()
|
||||
|
||||
generated, err := dkim.Generate(algorithm)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
signer, err := dkim.ParsePrivateKey(generated.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
ring[domain+"/default"] = signer.Public()
|
||||
|
||||
fixed := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
return NewSealer(domain, "default", "mx."+domain, signer).
|
||||
WithClock(func() time.Time { return fixed })
|
||||
}
|
||||
|
||||
func passingResult() port.InboundAuthResult {
|
||||
return port.InboundAuthResult{
|
||||
SPF: "pass", DKIM: "pass", DMARC: "pass",
|
||||
AuthenticationResults: "mx.forwarder.test; spf=pass; dkim=pass; dmarc=pass",
|
||||
}
|
||||
}
|
||||
|
||||
// A first sealer starts the chain: instance 1 with cv=none
|
||||
// (RFC 8617 section 5.1.1).
|
||||
func TestSealer_StartsChainAtInstanceOneWithCvNone(t *testing.T) {
|
||||
ring := keyring{}
|
||||
sealed, err := newSealer(t, "forwarder.test", dkim.AlgorithmRSA2048, ring).
|
||||
Seal(context.Background(), []byte(forwardedMessage), passingResult())
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
out := string(sealed)
|
||||
for _, name := range []string{HeaderSeal, HeaderMessageSignature, HeaderAuthenticationResults} {
|
||||
if !strings.Contains(out, name+":") {
|
||||
t.Errorf("missing %s:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "cv=none") {
|
||||
t.Errorf("first seal must report cv=none:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "i=1") {
|
||||
t.Errorf("first seal must be instance 1:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "d=forwarder.test") {
|
||||
t.Errorf("seal does not claim the sealing domain:\n%s", out)
|
||||
}
|
||||
// The original message must survive untouched below the new headers.
|
||||
if !strings.HasSuffix(out, forwardedMessage) {
|
||||
t.Error("the original message was modified")
|
||||
}
|
||||
}
|
||||
|
||||
// The header order is prescribed: the seal is computed last and sits on top.
|
||||
func TestSealer_WritesHeadersInPrescribedOrder(t *testing.T) {
|
||||
ring := keyring{}
|
||||
sealed, _ := newSealer(t, "forwarder.test", dkim.AlgorithmRSA2048, ring).
|
||||
Seal(context.Background(), []byte(forwardedMessage), passingResult())
|
||||
|
||||
out := string(sealed)
|
||||
sealAt := strings.Index(out, HeaderSeal+":")
|
||||
amsAt := strings.Index(out, HeaderMessageSignature+":")
|
||||
aarAt := strings.Index(out, HeaderAuthenticationResults+":")
|
||||
|
||||
if !(sealAt < amsAt && amsAt < aarAt) {
|
||||
t.Errorf("header order is seal=%d ams=%d aar=%d, want seal first", sealAt, amsAt, aarAt)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point: a chain we sealed must verify.
|
||||
func TestSealer_ProducesVerifiableChain(t *testing.T) {
|
||||
for _, algorithm := range []string{dkim.AlgorithmRSA2048, dkim.AlgorithmEd25519} {
|
||||
t.Run(algorithm, func(t *testing.T) {
|
||||
ring := keyring{}
|
||||
sealed, err := newSealer(t, "forwarder.test", algorithm, ring).
|
||||
Seal(context.Background(), []byte(forwardedMessage), passingResult())
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
state, err := VerifyChain(sealed, ring.lookup)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyChain: %v", err)
|
||||
}
|
||||
if state != ChainPass {
|
||||
t.Errorf("chain state = %s, want pass", state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A second forwarder extends the chain, and both sets must still verify.
|
||||
func TestSealer_ExtendsExistingChain(t *testing.T) {
|
||||
ring := keyring{}
|
||||
|
||||
first, err := newSealer(t, "first.test", dkim.AlgorithmRSA2048, ring).
|
||||
Seal(context.Background(), []byte(forwardedMessage), passingResult())
|
||||
if err != nil {
|
||||
t.Fatalf("first Seal: %v", err)
|
||||
}
|
||||
|
||||
second, err := newSealer(t, "second.test", dkim.AlgorithmEd25519, ring).
|
||||
Seal(context.Background(), first, passingResult())
|
||||
if err != nil {
|
||||
t.Fatalf("second Seal: %v", err)
|
||||
}
|
||||
|
||||
out := string(second)
|
||||
if !strings.Contains(out, "i=2") {
|
||||
t.Errorf("the second hop did not take instance 2:\n%s", out)
|
||||
}
|
||||
// The second hop saw an intact chain, so it reports cv=pass.
|
||||
if !strings.Contains(out, "cv=pass") {
|
||||
t.Errorf("the second seal should report cv=pass:\n%s", out)
|
||||
}
|
||||
if count := strings.Count(out, HeaderSeal+":"); count != 2 {
|
||||
t.Errorf("found %d seals, want 2", count)
|
||||
}
|
||||
|
||||
state, err := VerifyChain(second, ring.lookup)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyChain: %v", err)
|
||||
}
|
||||
if state != ChainPass {
|
||||
t.Errorf("chain state = %s, want pass", state)
|
||||
}
|
||||
}
|
||||
|
||||
// Modifying the body after sealing must break the message signature - this is
|
||||
// what proves the body hash is actually covering anything.
|
||||
func TestVerifyChain_DetectsBodyTampering(t *testing.T) {
|
||||
ring := keyring{}
|
||||
sealed, _ := newSealer(t, "forwarder.test", dkim.AlgorithmRSA2048, ring).
|
||||
Seal(context.Background(), []byte(forwardedMessage), passingResult())
|
||||
|
||||
tampered := strings.Replace(string(sealed), "the body", "evil body", 1)
|
||||
|
||||
state, err := VerifyChain([]byte(tampered), ring.lookup)
|
||||
if err == nil {
|
||||
t.Fatal("expected tampering to be detected")
|
||||
}
|
||||
if state != ChainFail {
|
||||
t.Errorf("chain state = %s, want fail", state)
|
||||
}
|
||||
}
|
||||
|
||||
// Modifying a signed header must break the signature too.
|
||||
func TestVerifyChain_DetectsHeaderTampering(t *testing.T) {
|
||||
ring := keyring{}
|
||||
sealed, _ := newSealer(t, "forwarder.test", dkim.AlgorithmRSA2048, ring).
|
||||
Seal(context.Background(), []byte(forwardedMessage), passingResult())
|
||||
|
||||
tampered := strings.Replace(string(sealed), "Subject: hello", "Subject: forged", 1)
|
||||
|
||||
if state, err := VerifyChain([]byte(tampered), ring.lookup); err == nil || state != ChainFail {
|
||||
t.Errorf("state = %s, err = %v; want a detected failure", state, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A message with no chain is "none", not a failure.
|
||||
func TestVerifyChain_NoChainIsNone(t *testing.T) {
|
||||
state, err := VerifyChain([]byte(forwardedMessage), keyring{}.lookup)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyChain: %v", err)
|
||||
}
|
||||
if state != ChainNone {
|
||||
t.Errorf("state = %s, want none", state)
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8617 section 5.1.2: a broken chain must not be extended.
|
||||
func TestSealer_RefusesToExtendFailedChain(t *testing.T) {
|
||||
ring := keyring{}
|
||||
failed := "ARC-Seal: i=1; a=rsa-sha256; cv=fail; d=broken.test; s=default; t=1; b=AAAA\r\n" +
|
||||
forwardedMessage
|
||||
|
||||
sealed, err := newSealer(t, "forwarder.test", dkim.AlgorithmRSA2048, ring).
|
||||
Seal(context.Background(), []byte(failed), passingResult())
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
|
||||
if string(sealed) != failed {
|
||||
t.Error("a failed chain was extended instead of being left alone")
|
||||
}
|
||||
}
|
||||
|
||||
// The relaxed body canonicalization of RFC 6376 section 3.4.4 must ignore
|
||||
// trailing whitespace and trailing empty lines, so cosmetic changes in transit
|
||||
// do not break the signature.
|
||||
func TestBodyHash_IgnoresCosmeticWhitespace(t *testing.T) {
|
||||
base := bodyHash("line one\r\nline two\r\n")
|
||||
|
||||
equivalent := []string{
|
||||
"line one \r\nline two\r\n",
|
||||
"line one\r\nline two\r\n\r\n\r\n",
|
||||
"line one\r\nline two\r\n",
|
||||
}
|
||||
for _, variant := range equivalent {
|
||||
if bodyHash(variant) != base {
|
||||
t.Errorf("body hash changed for a cosmetically equivalent body: %q", variant)
|
||||
}
|
||||
}
|
||||
|
||||
if bodyHash("line one\r\nline three\r\n") == base {
|
||||
t.Error("body hash did not change for genuinely different content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeHeaderRelaxed(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Subject: hello": "subject:hello",
|
||||
"SUBJECT: hello ": "subject:hello",
|
||||
"Subject:\thello there": "subject:hello there",
|
||||
}
|
||||
for input, want := range cases {
|
||||
name, value, _ := strings.Cut(input, ":")
|
||||
if got := canonicalizeHeaderRelaxed(name, value); got != want {
|
||||
t.Errorf("canonicalizeHeaderRelaxed(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package arc
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifyChain checks an ARC chain against the public keys of the sealing
|
||||
// domains and returns the chain validation state a downstream hop should
|
||||
// report in its own "cv=" tag.
|
||||
//
|
||||
// publicKeyFor resolves the key for a domain and selector; it is injected so
|
||||
// the caller decides how lookups happen (DNS in production, a fixed map in a
|
||||
// test).
|
||||
func VerifyChain(message []byte, publicKeyFor func(domain, selector string) (crypto.PublicKey, error)) (ChainValidation, error) {
|
||||
headers, body := splitMessage(string(message))
|
||||
|
||||
highest := 0
|
||||
for _, h := range headers {
|
||||
if strings.EqualFold(h.name, HeaderSeal) {
|
||||
if i, err := strconv.Atoi(parseTags(h.value)["i"]); err == nil && i > highest {
|
||||
highest = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if highest == 0 {
|
||||
return ChainNone, nil
|
||||
}
|
||||
if highest > maxChainInstance {
|
||||
return ChainFail, fmt.Errorf("arc: chain exceeds the maximum of %d instances", maxChainInstance)
|
||||
}
|
||||
|
||||
// RFC 8617 section 5.2: the most recent ARC-Message-Signature must
|
||||
// validate, and every ARC-Seal in the chain must validate.
|
||||
if err := verifyMessageSignature(headers, body, highest, publicKeyFor); err != nil {
|
||||
return ChainFail, err
|
||||
}
|
||||
|
||||
for instance := 1; instance <= highest; instance++ {
|
||||
if err := verifySeal(headers, instance, publicKeyFor); err != nil {
|
||||
return ChainFail, err
|
||||
}
|
||||
}
|
||||
|
||||
return ChainPass, nil
|
||||
}
|
||||
|
||||
func verifyMessageSignature(headers []header, body string, instance int, publicKeyFor func(string, string) (crypto.PublicKey, error)) error {
|
||||
value, ok := findInstanceHeader(headers, HeaderMessageSignature, instance)
|
||||
if !ok {
|
||||
return fmt.Errorf("arc: no %s for instance %d", HeaderMessageSignature, instance)
|
||||
}
|
||||
|
||||
tags := parseTags(value)
|
||||
if tags["bh"] != bodyHash(body) {
|
||||
return fmt.Errorf("arc: body hash mismatch at instance %d", instance)
|
||||
}
|
||||
|
||||
signedNames := strings.Split(tags["h"], ":")
|
||||
material := canonicalHeaderBlock(headers, signedNames)
|
||||
material += canonicalizeHeaderRelaxed(HeaderMessageSignature, stripSignature(value))
|
||||
|
||||
return verifySignature(tags, []byte(material), publicKeyFor)
|
||||
}
|
||||
|
||||
func verifySeal(headers []header, instance int, publicKeyFor func(string, string) (crypto.PublicKey, error)) error {
|
||||
value, ok := findInstanceHeader(headers, HeaderSeal, instance)
|
||||
if !ok {
|
||||
return fmt.Errorf("arc: no %s for instance %d", HeaderSeal, instance)
|
||||
}
|
||||
|
||||
var material strings.Builder
|
||||
for i := 1; i < instance; i++ {
|
||||
for _, name := range []string{HeaderAuthenticationResults, HeaderMessageSignature, HeaderSeal} {
|
||||
if previous, ok := findInstanceHeader(headers, name, i); ok {
|
||||
material.WriteString(canonicalizeHeaderRelaxed(name, previous))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if aar, ok := findInstanceHeader(headers, HeaderAuthenticationResults, instance); ok {
|
||||
material.WriteString(canonicalizeHeaderRelaxed(HeaderAuthenticationResults, aar))
|
||||
}
|
||||
if ams, ok := findInstanceHeader(headers, HeaderMessageSignature, instance); ok {
|
||||
material.WriteString(canonicalizeHeaderRelaxed(HeaderMessageSignature, ams))
|
||||
}
|
||||
material.WriteString(canonicalizeHeaderRelaxed(HeaderSeal, stripSignature(value)))
|
||||
|
||||
return verifySignature(parseTags(value), []byte(material.String()), publicKeyFor)
|
||||
}
|
||||
|
||||
// stripSignature blanks the "b=" tag, which is how the signature over a header
|
||||
// that contains itself is defined (RFC 6376 section 3.7).
|
||||
func stripSignature(value string) string {
|
||||
index := strings.LastIndex(value, "b=")
|
||||
if index < 0 {
|
||||
return value
|
||||
}
|
||||
return value[:index+2]
|
||||
}
|
||||
|
||||
func verifySignature(tags map[string]string, material []byte, publicKeyFor func(string, string) (crypto.PublicKey, error)) error {
|
||||
signature, err := base64.StdEncoding.DecodeString(tags["b"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("arc: signature is not valid base64: %w", err)
|
||||
}
|
||||
|
||||
key, err := publicKeyFor(tags["d"], tags["s"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("arc: cannot resolve key for %s/%s: %w", tags["d"], tags["s"], err)
|
||||
}
|
||||
|
||||
switch typed := key.(type) {
|
||||
case ed25519.PublicKey:
|
||||
if !ed25519.Verify(typed, material, signature) {
|
||||
return fmt.Errorf("arc: ed25519 signature does not verify for %s", tags["d"])
|
||||
}
|
||||
return nil
|
||||
case *rsa.PublicKey:
|
||||
digest := sha256.Sum256(material)
|
||||
if err := rsa.VerifyPKCS1v15(typed, crypto.SHA256, digest[:], signature); err != nil {
|
||||
return fmt.Errorf("arc: rsa signature does not verify for %s: %w", tags["d"], err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("arc: unsupported key type %T", key)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user