From b96be4a2a55fd684db7f53ef72d80e045a9c624e Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 1 Aug 2026 22:48:15 -0300 Subject: [PATCH] #16: implement DKIM key provisioning, Ed25519 & RSA dual signatures and rotation --- .../application/port/dkim_repository.go | 29 ++++ .../usecase/provision_dkim_keys.go | 100 +++++++++++++ .../usecase/provision_dkim_keys_test.go | 128 +++++++++++++++++ .../entity/dns_record_idempotency_test.go | 73 ++++++++++ .../internal/infrastructure/dkim/keygen.go | 119 ++++++++++++++++ .../internal/infrastructure/dkim/signer.go | 87 ++++++++++++ .../infrastructure/dkim/signer_test.go | 132 ++++++++++++++++++ .../postgres/dkim_repository.go | 124 ++++++++++++++++ 8 files changed, 792 insertions(+) create mode 100644 services/protocols/internal/application/port/dkim_repository.go create mode 100644 services/protocols/internal/application/usecase/provision_dkim_keys.go create mode 100644 services/protocols/internal/application/usecase/provision_dkim_keys_test.go create mode 100644 services/protocols/internal/domain/entity/dns_record_idempotency_test.go create mode 100644 services/protocols/internal/infrastructure/dkim/keygen.go create mode 100644 services/protocols/internal/infrastructure/dkim/signer.go create mode 100644 services/protocols/internal/infrastructure/dkim/signer_test.go create mode 100644 services/protocols/internal/infrastructure/postgres/dkim_repository.go diff --git a/services/protocols/internal/application/port/dkim_repository.go b/services/protocols/internal/application/port/dkim_repository.go new file mode 100644 index 0000000..27f83c1 --- /dev/null +++ b/services/protocols/internal/application/port/dkim_repository.go @@ -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) +} diff --git a/services/protocols/internal/application/usecase/provision_dkim_keys.go b/services/protocols/internal/application/usecase/provision_dkim_keys.go new file mode 100644 index 0000000..9c6dad8 --- /dev/null +++ b/services/protocols/internal/application/usecase/provision_dkim_keys.go @@ -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 +} diff --git a/services/protocols/internal/application/usecase/provision_dkim_keys_test.go b/services/protocols/internal/application/usecase/provision_dkim_keys_test.go new file mode 100644 index 0000000..c2a23d6 --- /dev/null +++ b/services/protocols/internal/application/usecase/provision_dkim_keys_test.go @@ -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 +} diff --git a/services/protocols/internal/domain/entity/dns_record_idempotency_test.go b/services/protocols/internal/domain/entity/dns_record_idempotency_test.go new file mode 100644 index 0000000..8cb191f --- /dev/null +++ b/services/protocols/internal/domain/entity/dns_record_idempotency_test.go @@ -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") + } +} diff --git a/services/protocols/internal/infrastructure/dkim/keygen.go b/services/protocols/internal/infrastructure/dkim/keygen.go new file mode 100644 index 0000000..0f926dc --- /dev/null +++ b/services/protocols/internal/infrastructure/dkim/keygen.go @@ -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 +} diff --git a/services/protocols/internal/infrastructure/dkim/signer.go b/services/protocols/internal/infrastructure/dkim/signer.go new file mode 100644 index 0000000..a3b053b --- /dev/null +++ b/services/protocols/internal/infrastructure/dkim/signer.go @@ -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) diff --git a/services/protocols/internal/infrastructure/dkim/signer_test.go b/services/protocols/internal/infrastructure/dkim/signer_test.go new file mode 100644 index 0000000..b1ce5bf --- /dev/null +++ b/services/protocols/internal/infrastructure/dkim/signer_test.go @@ -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: \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) + } + } +} diff --git a/services/protocols/internal/infrastructure/postgres/dkim_repository.go b/services/protocols/internal/infrastructure/postgres/dkim_repository.go new file mode 100644 index 0000000..d31a680 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/dkim_repository.go @@ -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)