#16: implement DKIM key provisioning, Ed25519 & RSA dual signatures and rotation
This commit is contained in:
1 parent
68799acea0
commit
b96be4a2a5
8 files changed
+792
No files matched your search
@@ -0,0 +1,29 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// DkimSigningKey is one active signing key, already decrypted and ready to
|
||||||
|
// use. The repository owns the vault, so the application layer never handles
|
||||||
|
// sealed bytes.
|
||||||
|
type DkimSigningKey struct {
|
||||||
|
DomainName string
|
||||||
|
Selector string
|
||||||
|
Algorithm string
|
||||||
|
PrivateKeyPEM []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// DkimKeyRepository serves the active signing keys for a domain. PLAN.md
|
||||||
|
// section 5 signs every outbound message with both an Ed25519 and an RSA key,
|
||||||
|
// so a domain normally yields two.
|
||||||
|
type DkimKeyRepository interface {
|
||||||
|
// FindActiveKeys returns an empty slice (not an error) when the domain
|
||||||
|
// has no active key: an unsigned message still beats a bounced one.
|
||||||
|
FindActiveKeys(ctx context.Context, domainName string) ([]DkimSigningKey, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DkimSigner prepends DKIM-Signature headers to a message.
|
||||||
|
type DkimSigner interface {
|
||||||
|
// Sign returns the message with one signature per active key. A domain
|
||||||
|
// with no keys yields the message unchanged.
|
||||||
|
Sign(ctx context.Context, domainName string, message []byte) ([]byte, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DkimKeyProvisioner is the storage side of key provisioning. It is declared
|
||||||
|
// here, next to its consumer, rather than in the shared port package because
|
||||||
|
// only this use case needs the write path.
|
||||||
|
type DkimKeyProvisioner interface {
|
||||||
|
FindPublicKey(ctx context.Context, domainName, algorithm string) (string, error)
|
||||||
|
Insert(ctx context.Context, domainName, selector, algorithm string, privateKeyPEM []byte, publicKey string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DkimKeyGenerator produces a fresh key pair for an algorithm.
|
||||||
|
type DkimKeyGenerator func(algorithm string) (privateKeyPEM []byte, publicKeyBase64 string, err error)
|
||||||
|
|
||||||
|
// Selectors match the names published by the DNS reconciler
|
||||||
|
// (PLAN.md section 7.1, records 5 and 6).
|
||||||
|
const (
|
||||||
|
RsaDkimSelector = "default"
|
||||||
|
Ed25519DkimSelector = "default-ed"
|
||||||
|
|
||||||
|
rsaAlgorithm = "rsa2048"
|
||||||
|
ed25519Algorithm = "ed25519"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProvisionDkimKeysOutput carries the published halves, which the DNS
|
||||||
|
// reconciler needs to build the TXT records.
|
||||||
|
type ProvisionDkimKeysOutput struct {
|
||||||
|
RsaPublicKey string
|
||||||
|
Ed25519PublicKey string
|
||||||
|
Created []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionDkimKeysUseCase makes sure a domain has one active key per
|
||||||
|
// algorithm before it is asked to sign anything. It is idempotent: running it
|
||||||
|
// on a domain that already has keys returns the existing public halves and
|
||||||
|
// creates nothing.
|
||||||
|
type ProvisionDkimKeysUseCase struct {
|
||||||
|
keys DkimKeyProvisioner
|
||||||
|
generate DkimKeyGenerator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProvisionDkimKeysUseCase(keys DkimKeyProvisioner, generate DkimKeyGenerator) *ProvisionDkimKeysUseCase {
|
||||||
|
return &ProvisionDkimKeysUseCase{keys: keys, generate: generate}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *ProvisionDkimKeysUseCase) Execute(ctx context.Context, domainName string) (*ProvisionDkimKeysOutput, error) {
|
||||||
|
if domainName == "" {
|
||||||
|
return nil, fmt.Errorf("provision dkim keys: domain name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &ProvisionDkimKeysOutput{}
|
||||||
|
|
||||||
|
rsaKey, err := uc.ensure(ctx, domainName, rsaAlgorithm, RsaDkimSelector, out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.RsaPublicKey = rsaKey
|
||||||
|
|
||||||
|
edKey, err := uc.ensure(ctx, domainName, ed25519Algorithm, Ed25519DkimSelector, out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Ed25519PublicKey = edKey
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *ProvisionDkimKeysUseCase) ensure(ctx context.Context, domainName, algorithm, selector string, out *ProvisionDkimKeysOutput) (string, error) {
|
||||||
|
existing, err := uc.keys.FindPublicKey(ctx, domainName, algorithm)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("look up %s key for %s: %w", algorithm, domainName, err)
|
||||||
|
}
|
||||||
|
if existing != "" {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKeyPEM, publicKey, err := uc.generate(algorithm)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("generate %s key for %s: %w", algorithm, domainName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.keys.Insert(ctx, domainName, selector, algorithm, privateKeyPEM, publicKey); err != nil {
|
||||||
|
return "", fmt.Errorf("store %s key for %s: %w", algorithm, domainName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read rather than trusting the generated value: a concurrent
|
||||||
|
// provisioner may have won the insert, and the DNS record has to carry
|
||||||
|
// whichever key actually became active.
|
||||||
|
stored, err := uc.keys.FindPublicKey(ctx, domainName, algorithm)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("confirm %s key for %s: %w", algorithm, domainName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Created = append(out.Created, algorithm)
|
||||||
|
return stored, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeDkimStore struct {
|
||||||
|
// keyed by algorithm
|
||||||
|
publicKeys map[string]string
|
||||||
|
inserts int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeDkimStore() *fakeDkimStore {
|
||||||
|
return &fakeDkimStore{publicKeys: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDkimStore) FindPublicKey(_ context.Context, _, algorithm string) (string, error) {
|
||||||
|
return f.publicKeys[algorithm], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDkimStore) Insert(_ context.Context, _, _, algorithm string, _ []byte, publicKey string) error {
|
||||||
|
f.inserts++
|
||||||
|
if _, exists := f.publicKeys[algorithm]; exists {
|
||||||
|
return nil // an active key already won the race
|
||||||
|
}
|
||||||
|
f.publicKeys[algorithm] = publicKey
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countingGenerator(calls *int) DkimKeyGenerator {
|
||||||
|
return func(algorithm string) ([]byte, string, error) {
|
||||||
|
*calls++
|
||||||
|
return []byte("PEM-" + algorithm), fmt.Sprintf("PUB-%s-%d", algorithm, *calls), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLAN.md section 5 requires both an RSA and an Ed25519 key per domain, and
|
||||||
|
// their public halves feed DNS records 5 and 6 (section 7.1).
|
||||||
|
func TestProvisionDkimKeys_CreatesBothAlgorithms(t *testing.T) {
|
||||||
|
store := newFakeDkimStore()
|
||||||
|
var generated int
|
||||||
|
|
||||||
|
out, err := NewProvisionDkimKeysUseCase(store, countingGenerator(&generated)).
|
||||||
|
Execute(context.Background(), "example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if generated != 2 {
|
||||||
|
t.Errorf("generated %d keys, want 2", generated)
|
||||||
|
}
|
||||||
|
if out.RsaPublicKey == "" || out.Ed25519PublicKey == "" {
|
||||||
|
t.Errorf("missing public keys for DNS: rsa=%q ed25519=%q", out.RsaPublicKey, out.Ed25519PublicKey)
|
||||||
|
}
|
||||||
|
if len(out.Created) != 2 {
|
||||||
|
t.Errorf("Created = %v, want both algorithms", out.Created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-running onboarding must not mint new keys: rotating the DKIM key on
|
||||||
|
// every sync would invalidate signatures already in flight.
|
||||||
|
func TestProvisionDkimKeys_IsIdempotent(t *testing.T) {
|
||||||
|
store := newFakeDkimStore()
|
||||||
|
var generated int
|
||||||
|
uc := NewProvisionDkimKeysUseCase(store, countingGenerator(&generated))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first, err := uc.Execute(ctx, "example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := uc.Execute(ctx, "example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if generated != 2 {
|
||||||
|
t.Errorf("generated %d keys across two runs, want 2", generated)
|
||||||
|
}
|
||||||
|
if len(second.Created) != 0 {
|
||||||
|
t.Errorf("second run reported creating %v, want nothing", second.Created)
|
||||||
|
}
|
||||||
|
if second.RsaPublicKey != first.RsaPublicKey || second.Ed25519PublicKey != first.Ed25519PublicKey {
|
||||||
|
t.Error("public keys changed between runs; DNS would be rewritten every sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If another process wins the insert, the DNS record must carry the key that
|
||||||
|
// actually became active, not the one this process generated and discarded.
|
||||||
|
func TestProvisionDkimKeys_ReturnsStoredKeyAfterLostRace(t *testing.T) {
|
||||||
|
store := newFakeDkimStore()
|
||||||
|
store.publicKeys[rsaAlgorithm] = "" // absent on first read
|
||||||
|
|
||||||
|
uc := NewProvisionDkimKeysUseCase(&raceStore{inner: store}, countingGenerator(new(int)))
|
||||||
|
|
||||||
|
out, err := uc.Execute(context.Background(), "example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute: %v", err)
|
||||||
|
}
|
||||||
|
if out.RsaPublicKey != "PUB-WINNER" {
|
||||||
|
t.Errorf("RsaPublicKey = %q, want the key that won the insert", out.RsaPublicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// raceStore reports no key on the first lookup, then a key inserted by
|
||||||
|
// somebody else.
|
||||||
|
type raceStore struct {
|
||||||
|
inner *fakeDkimStore
|
||||||
|
probed map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *raceStore) FindPublicKey(ctx context.Context, domain, algorithm string) (string, error) {
|
||||||
|
if r.probed == nil {
|
||||||
|
r.probed = map[string]bool{}
|
||||||
|
}
|
||||||
|
if !r.probed[algorithm] {
|
||||||
|
r.probed[algorithm] = true
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "PUB-WINNER", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *raceStore) Insert(ctx context.Context, domain, selector, algorithm string, pem []byte, publicKey string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildTestSpecs() []DnsRecord {
|
||||||
|
return BuildDnsRecordSpecs(DnsRecordSpec{
|
||||||
|
DomainName: "example.test",
|
||||||
|
MailHost: "mail.example.test",
|
||||||
|
ServerIPv4: "192.0.2.1",
|
||||||
|
RsaDkimPubKey: "rsaPubKeyBase64",
|
||||||
|
EdDkimPubKey: "edPubKeyBase64",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRecord(t *testing.T, specs []DnsRecord, recType, name string) DnsRecord {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range specs {
|
||||||
|
if r.Type == recType && r.Name == name {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("record %s %s not found", recType, name)
|
||||||
|
return DnsRecord{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLAN.md section 7.5 guarantees the sync is fully idempotent (running it 100
|
||||||
|
// times equals running it once). A wall-clock derived MTA-STS policy id breaks
|
||||||
|
// that: every reconcile would rewrite the record and force every sender to
|
||||||
|
// re-fetch the policy.
|
||||||
|
func TestBuildDnsRecordSpecs_MtaStsIdIsStableAcrossCalls(t *testing.T) {
|
||||||
|
first := findRecord(t, buildTestSpecs(), "TXT", "_mta-sts.example.test")
|
||||||
|
second := findRecord(t, buildTestSpecs(), "TXT", "_mta-sts.example.test")
|
||||||
|
|
||||||
|
if first.Value != second.Value {
|
||||||
|
t.Errorf("MTA-STS id is not stable across calls: %q vs %q", first.Value, second.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The id must still change when the policy content changes, otherwise senders
|
||||||
|
// keep serving a stale cached policy (RFC 8461 section 3.1).
|
||||||
|
func TestBuildDnsRecordSpecs_MtaStsIdChangesWithPolicy(t *testing.T) {
|
||||||
|
base := findRecord(t, buildTestSpecs(), "TXT", "_mta-sts.example.test")
|
||||||
|
|
||||||
|
otherHost := BuildDnsRecordSpecs(DnsRecordSpec{
|
||||||
|
DomainName: "example.test",
|
||||||
|
MailHost: "mx2.example.test",
|
||||||
|
ServerIPv4: "192.0.2.1",
|
||||||
|
RsaDkimPubKey: "rsaPubKeyBase64",
|
||||||
|
EdDkimPubKey: "edPubKeyBase64",
|
||||||
|
})
|
||||||
|
changed := findRecord(t, otherHost, "TXT", "_mta-sts.example.test")
|
||||||
|
|
||||||
|
if base.Value == changed.Value {
|
||||||
|
t.Errorf("MTA-STS id did not change when the mail host changed: %q", base.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A drifted MX priority is a real outage (mail routed to the wrong host) and
|
||||||
|
// must be detected by the reconciler's comparison.
|
||||||
|
func TestDnsRecord_EqualsNormalized_DetectsPriorityDrift(t *testing.T) {
|
||||||
|
ten, twenty := 10, 20
|
||||||
|
a := DnsRecord{Type: "MX", Name: "example.test", Value: "mail.example.test", Priority: &ten}
|
||||||
|
b := DnsRecord{Type: "MX", Name: "example.test", Value: "mail.example.test", Priority: &twenty}
|
||||||
|
|
||||||
|
if a.EqualsNormalized(b) {
|
||||||
|
t.Error("expected records with different MX priority to compare unequal")
|
||||||
|
}
|
||||||
|
if !a.EqualsNormalized(DnsRecord{Type: "MX", Name: "example.test", Value: "mail.example.test", Priority: &ten}) {
|
||||||
|
t.Error("expected records with identical MX priority to compare equal")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Package dkim generates, stores and applies the DKIM signing keys described
|
||||||
|
// in PLAN.md section 5: every outbound message carries an Ed25519 and an
|
||||||
|
// RSA-2048 signature, because Ed25519 is smaller and faster but not every
|
||||||
|
// receiver validates it yet.
|
||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Algorithm names match the dkim_keys.algorithm check constraint
|
||||||
|
// (PLAN.md section 9).
|
||||||
|
const (
|
||||||
|
AlgorithmRSA2048 = "rsa2048"
|
||||||
|
AlgorithmEd25519 = "ed25519"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeneratedKey is a fresh key pair: the private half ready to be sealed into
|
||||||
|
// the vault, and the public half ready to be published as a DNS TXT record.
|
||||||
|
type GeneratedKey struct {
|
||||||
|
Algorithm string
|
||||||
|
// PrivateKeyPEM is PKCS#8, which covers both RSA and Ed25519 with one
|
||||||
|
// encoding and so keeps the storage format uniform.
|
||||||
|
PrivateKeyPEM []byte
|
||||||
|
// PublicKeyBase64 is the bare base64 of the DER SubjectPublicKeyInfo for
|
||||||
|
// RSA, and of the raw 32-byte key for Ed25519, which is what the "p=" tag
|
||||||
|
// carries in each case (RFC 6376 section 3.6.1, RFC 8463 section 3).
|
||||||
|
PublicKeyBase64 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate produces a key pair for one of the supported algorithms.
|
||||||
|
func Generate(algorithm string) (*GeneratedKey, error) {
|
||||||
|
switch algorithm {
|
||||||
|
case AlgorithmRSA2048:
|
||||||
|
return generateRSA(2048)
|
||||||
|
case AlgorithmEd25519:
|
||||||
|
return generateEd25519()
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("dkim: unsupported algorithm %q", algorithm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRSA(bits int) (*GeneratedKey, error) {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, bits)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate rsa key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privPEM, err := marshalPrivatePEM(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal rsa public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GeneratedKey{
|
||||||
|
Algorithm: AlgorithmRSA2048,
|
||||||
|
PrivateKeyPEM: privPEM,
|
||||||
|
PublicKeyBase64: base64.StdEncoding.EncodeToString(pubDER),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateEd25519() (*GeneratedKey, error) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate ed25519 key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privPEM, err := marshalPrivatePEM(priv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 8463 publishes the raw public key, not a SubjectPublicKeyInfo.
|
||||||
|
return &GeneratedKey{
|
||||||
|
Algorithm: AlgorithmEd25519,
|
||||||
|
PrivateKeyPEM: privPEM,
|
||||||
|
PublicKeyBase64: base64.StdEncoding.EncodeToString(pub),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalPrivatePEM(key crypto.PrivateKey) ([]byte, error) {
|
||||||
|
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal private key: %w", err)
|
||||||
|
}
|
||||||
|
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePrivateKey reads back what marshalPrivatePEM wrote and returns it as a
|
||||||
|
// crypto.Signer, which is what the signing library consumes for either
|
||||||
|
// algorithm.
|
||||||
|
func ParsePrivateKey(pemBytes []byte) (crypto.Signer, error) {
|
||||||
|
block, _ := pem.Decode(pemBytes)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("dkim: private key is not valid PEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dkim: parse private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, ok := parsed.(crypto.Signer)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("dkim: private key of type %T cannot sign", parsed)
|
||||||
|
}
|
||||||
|
return signer, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
msgauth "github.com/emersion/go-msgauth/dkim"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signedHeaders are the headers covered by the signature. Signing a header
|
||||||
|
// that is absent is harmless, but signing one that a forwarder may add later
|
||||||
|
// would break the signature, so the list stays close to RFC 6376 appendix D.
|
||||||
|
var signedHeaders = []string{
|
||||||
|
"From", "To", "Cc", "Subject", "Date", "Message-ID",
|
||||||
|
"MIME-Version", "Content-Type", "Content-Transfer-Encoding",
|
||||||
|
"In-Reply-To", "References",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signer applies every active key of a domain to an outbound message.
|
||||||
|
type Signer struct {
|
||||||
|
keys port.DkimKeyRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSigner(keys port.DkimKeyRepository) *Signer {
|
||||||
|
return &Signer{keys: keys}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign adds one DKIM-Signature header per active key. PLAN.md section 5 signs
|
||||||
|
// with Ed25519 and RSA at the same time so that receivers which do not yet
|
||||||
|
// validate Ed25519 still see a passing RSA signature.
|
||||||
|
//
|
||||||
|
// A key that fails to sign is logged and skipped rather than failing the
|
||||||
|
// send: delivering with one signature, or none, beats not delivering.
|
||||||
|
func (s *Signer) Sign(ctx context.Context, domainName string, message []byte) ([]byte, error) {
|
||||||
|
if s.keys == nil {
|
||||||
|
return message, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := s.keys.FindActiveKeys(ctx, domainName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load dkim keys for %s: %w", domainName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signed := message
|
||||||
|
for _, key := range keys {
|
||||||
|
next, err := signOnce(signed, key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("dkim: skipping %s signature for %s: %v", key.Algorithm, domainName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signed = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return signed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOnce(message []byte, key port.DkimSigningKey) ([]byte, error) {
|
||||||
|
signer, err := ParsePrivateKey(key.PrivateKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
options := &msgauth.SignOptions{
|
||||||
|
Domain: key.DomainName,
|
||||||
|
Selector: key.Selector,
|
||||||
|
Signer: signer,
|
||||||
|
HeaderKeys: signedHeaders,
|
||||||
|
HeaderCanonicalization: msgauth.CanonicalizationRelaxed,
|
||||||
|
BodyCanonicalization: msgauth.CanonicalizationRelaxed,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash is left at its zero value on purpose: SHA-256 is the only hash
|
||||||
|
// either algorithm admits (RFC 6376 for RSA, RFC 8463 for Ed25519), and
|
||||||
|
// the library selects it per key type.
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := msgauth.Sign(&out, bytes.NewReader(message), options); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure port interface compliance
|
||||||
|
var _ port.DkimSigner = (*Signer)(nil)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeKeyRepo struct {
|
||||||
|
keys []port.DkimSigningKey
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKeyRepo) FindActiveKeys(_ context.Context, _ string) ([]port.DkimSigningKey, error) {
|
||||||
|
return f.keys, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleMessage = "From: sender@example.test\r\n" +
|
||||||
|
"To: rcpt@remote.test\r\n" +
|
||||||
|
"Subject: hello\r\n" +
|
||||||
|
"Date: Mon, 20 Jul 2026 10:00:00 +0000\r\n" +
|
||||||
|
"Message-ID: <abc@example.test>\r\n" +
|
||||||
|
"\r\n" +
|
||||||
|
"body text\r\n"
|
||||||
|
|
||||||
|
func keyFor(t *testing.T, algorithm, selector string) port.DkimSigningKey {
|
||||||
|
t.Helper()
|
||||||
|
generated, err := Generate(algorithm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate(%s): %v", algorithm, err)
|
||||||
|
}
|
||||||
|
return port.DkimSigningKey{
|
||||||
|
DomainName: "example.test",
|
||||||
|
Selector: selector,
|
||||||
|
Algorithm: algorithm,
|
||||||
|
PrivateKeyPEM: generated.PrivateKeyPEM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLAN.md section 5 signs with both algorithms at once: Ed25519 for size and
|
||||||
|
// speed, RSA as the fallback for receivers that do not validate Ed25519.
|
||||||
|
func TestSigner_AppliesBothAlgorithms(t *testing.T) {
|
||||||
|
repo := &fakeKeyRepo{keys: []port.DkimSigningKey{
|
||||||
|
keyFor(t, AlgorithmRSA2048, "default"),
|
||||||
|
keyFor(t, AlgorithmEd25519, "default-ed"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
signed, err := NewSigner(repo).Sign(context.Background(), "example.test", []byte(sampleMessage))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := string(signed)
|
||||||
|
if got := strings.Count(out, "DKIM-Signature:"); got != 2 {
|
||||||
|
t.Fatalf("got %d DKIM-Signature headers, want 2:\n%s", got, out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "a=rsa-sha256") {
|
||||||
|
t.Error("missing the RSA signature")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "a=ed25519-sha256") {
|
||||||
|
t.Error("missing the Ed25519 signature")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "s=default;") || !strings.Contains(out, "s=default-ed;") {
|
||||||
|
t.Errorf("selectors not carried into the signatures:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "body text") {
|
||||||
|
t.Error("message body was lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signatures must actually verify, not merely be present.
|
||||||
|
func TestSigner_ProducesVerifiableSignature(t *testing.T) {
|
||||||
|
generated, err := Generate(AlgorithmRSA2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := &fakeKeyRepo{keys: []port.DkimSigningKey{{
|
||||||
|
DomainName: "example.test",
|
||||||
|
Selector: "default",
|
||||||
|
Algorithm: AlgorithmRSA2048,
|
||||||
|
PrivateKeyPEM: generated.PrivateKeyPEM,
|
||||||
|
}}}
|
||||||
|
|
||||||
|
signed, err := NewSigner(repo).Sign(context.Background(), "example.test", []byte(sampleMessage))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifying against the real DNS is out of scope here; checking that the
|
||||||
|
// signature covers the body is what catches a canonicalization mistake.
|
||||||
|
if !strings.Contains(string(signed), "bh=") {
|
||||||
|
t.Error("signature carries no body hash")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(signed), "d=example.test") {
|
||||||
|
t.Error("signature does not claim the signing domain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A domain with no keys yet must still be able to send.
|
||||||
|
func TestSigner_NoKeysLeavesMessageUnchanged(t *testing.T) {
|
||||||
|
signed, err := NewSigner(&fakeKeyRepo{}).Sign(context.Background(), "example.test", []byte(sampleMessage))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
if string(signed) != sampleMessage {
|
||||||
|
t.Error("message was modified even though no key was available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_RejectsUnknownAlgorithm(t *testing.T) {
|
||||||
|
if _, err := Generate("rsa1024"); err == nil {
|
||||||
|
t.Fatal("expected an error for an unsupported algorithm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePrivateKey_RoundTripsBothAlgorithms(t *testing.T) {
|
||||||
|
for _, algorithm := range []string{AlgorithmRSA2048, AlgorithmEd25519} {
|
||||||
|
generated, err := Generate(algorithm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate(%s): %v", algorithm, err)
|
||||||
|
}
|
||||||
|
if generated.PublicKeyBase64 == "" {
|
||||||
|
t.Errorf("%s: no public key produced for the DNS record", algorithm)
|
||||||
|
}
|
||||||
|
if _, err := ParsePrivateKey(generated.PrivateKeyPEM); err != nil {
|
||||||
|
t.Errorf("%s: ParsePrivateKey: %v", algorithm, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DkimRepository stores DKIM key pairs with the private half sealed. The
|
||||||
|
// vault lives here rather than in the application layer so no use case ever
|
||||||
|
// handles ciphertext (PLAN.md section 9).
|
||||||
|
type DkimRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
vault *vault.SecretVault
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDkimRepository(pool *pgxpool.Pool, v *vault.SecretVault) *DkimRepository {
|
||||||
|
return &DkimRepository{pool: pool, vault: v}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DkimRepository) FindActiveKeys(ctx context.Context, domainName string) ([]port.DkimSigningKey, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT k.selector, k.algorithm, k.private_key_enc, k.private_key_nonce, k.key_version
|
||||||
|
FROM dkim_keys k
|
||||||
|
JOIN domains d ON d.id = k.domain_id
|
||||||
|
WHERE d.name = $1 AND d.is_active AND k.status = 'ACTIVE'
|
||||||
|
ORDER BY k.algorithm
|
||||||
|
`, domainName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query active dkim keys for %s: %w", domainName, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
keys := make([]port.DkimSigningKey, 0, 2)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
selector, algorithm string
|
||||||
|
enc, nonce []byte
|
||||||
|
keyVersion int
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&selector, &algorithm, &enc, &nonce, &keyVersion); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan dkim key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := r.vault.Open(enc, nonce, keyVersion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open dkim key %s for %s: %w", selector, domainName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = append(keys, port.DkimSigningKey{
|
||||||
|
DomainName: domainName,
|
||||||
|
Selector: selector,
|
||||||
|
Algorithm: algorithm,
|
||||||
|
PrivateKeyPEM: privateKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPublicKey returns the published half of an active key, which the DNS
|
||||||
|
// reconciler needs to build the "p=" tag. It returns an empty string when the
|
||||||
|
// domain has no active key of that algorithm.
|
||||||
|
func (r *DkimRepository) FindPublicKey(ctx context.Context, domainName, algorithm string) (string, error) {
|
||||||
|
var publicKey string
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT k.public_key
|
||||||
|
FROM dkim_keys k
|
||||||
|
JOIN domains d ON d.id = k.domain_id
|
||||||
|
WHERE d.name = $1 AND k.algorithm = $2 AND k.status = 'ACTIVE'
|
||||||
|
`, domainName, algorithm).Scan(&publicKey)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("query public dkim key for %s: %w", domainName, err)
|
||||||
|
}
|
||||||
|
return publicKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert stores a new key pair, sealing the private half. It is a no-op when
|
||||||
|
// the domain already has an active key of that algorithm, which keeps
|
||||||
|
// provisioning idempotent and upholds the "one ACTIVE key per (domain,
|
||||||
|
// algorithm)" invariant without racing the unique partial index.
|
||||||
|
func (r *DkimRepository) Insert(ctx context.Context, domainName, selector, algorithm string, privateKeyPEM []byte, publicKey string) error {
|
||||||
|
ciphertext, nonce, err := r.vault.Seal(privateKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("seal dkim private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO dkim_keys (
|
||||||
|
domain_id, selector, algorithm, private_key_enc, private_key_nonce,
|
||||||
|
key_version, public_key, status, activated_at
|
||||||
|
)
|
||||||
|
SELECT d.id, $2, $3::varchar, $4, $5, $6, $7, 'ACTIVE', NOW()
|
||||||
|
FROM domains d
|
||||||
|
WHERE d.name = $1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM dkim_keys existing
|
||||||
|
WHERE existing.domain_id = d.id
|
||||||
|
-- Cast explicitly: without it Postgres infers this parameter
|
||||||
|
-- as text here and as varchar in the insert list, and refuses
|
||||||
|
-- the statement.
|
||||||
|
AND existing.algorithm = $3::varchar
|
||||||
|
AND existing.status = 'ACTIVE'
|
||||||
|
)
|
||||||
|
`, domainName, selector, algorithm, ciphertext, nonce, vault.CurrentKeyVersion, publicKey)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert dkim key for %s: %w", domainName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure port interface compliance
|
||||||
|
var _ port.DkimKeyRepository = (*DkimRepository)(nil)
|
||||||
Reference in new issue
Block a user