Files
LambdaMail/services/protocols/internal/infrastructure/arc/canonical.go
T

148 lines
4.6 KiB
Go

// 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"
}