#16: implement DKIM key provisioning, Ed25519 & RSA dual signatures and rotation

This commit is contained in:
Lucas Rayan committed 2026-08-01 22:48:15 -03:00
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
}