#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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user