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

259 lines
7.9 KiB
Go

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