A fresh deployment published "v=DKIM1; k=rsa; p=" for its own domain. An empty p= is not an absent key, it is a revoked one (RFC 6376 section 3.6.1): every verifier reading it treats all signatures from that selector as invalid. On a domain whose DMARC policy is p=quarantine with strict alignment - which this reconciler generates - that quarantines all of its own outbound mail. Worse than publishing nothing. The chain: DkimRepository.Insert is an INSERT ... SELECT conditional on the domain existing in the domains table, so on an empty database it inserts no rows and returns no error. FindPublicKey then finds nothing, ensure() returns an empty string with no error, and the reconciler faithfully published the empty value. Every field along that path was behaving as written; nothing checked that the result meant anything. Two changes: - The record builder omits a DKIM record when its key is empty, so the revocation cannot be published regardless of why the key is missing. - Provisioning logs when it produced no key, naming the likely cause. The insert being conditional on an onboarded domain is exactly the case a new operator hits, and "no key, domain probably not onboarded" is the difference between a diagnosis and unsigned mail with no explanation. It stays non-fatal: erroring here would abort the whole reconcile and leave a new install with no A, MX or SPF records either. Found while migrating to a new host: the fresh database had no domain row, so the first reconcile revoked the domain's DKIM. The two published records have been deleted from the zone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
3.8 KiB
Go
113 lines
3.8 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// The insert is conditional on the domain existing, and inserting nothing
|
|
// is not an error at the SQL level - so a domain that has never been
|
|
// onboarded silently produces no key here. Saying so is the difference
|
|
// between an operator seeing "DKIM is not set up yet, onboard the domain"
|
|
// and seeing unsigned mail with no explanation.
|
|
if stored == "" {
|
|
log.Printf("dkim: no %s key for %s after provisioning - the domain is probably not onboarded yet, so mail from it will not be signed",
|
|
algorithm, domainName)
|
|
return "", nil
|
|
}
|
|
|
|
out.Created = append(out.Created, algorithm)
|
|
return stored, nil
|
|
}
|