diff --git a/.gitignore b/.gitignore index 6925788..25606d7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,6 @@ cmd/maddy/maddy cmd/maddyctl/maddyctl cmd/maddy-*-helper/maddy-*-helper -# Config files -*.conf - # Certificates and private keys. *.pem *.crt diff --git a/README.md b/README.md index 86d4dbf..4434b16 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,14 @@ have any questions or just want to talk about maddy. - Minimal configuration changes required to get almost complete email stack running - [MTA-STS](https://www.hardenize.com/blog/mta-sts) support - DNS sanity checks for incoming deliveries -- Built-in [DKIM](https://blog.returnpath.com/how-to-explain-dkim-in-plain-english-2/) verification support +- Built-in [DKIM](https://blog.returnpath.com/how-to-explain-dkim-in-plain-english-2/) + verification and signing support - [Subaddressing](https://en.wikipedia.org/wiki/Email_address#Sub-addressing) (aka plus-addressing) support Planned features: - Built-in [DKIM](https://blog.returnpath.com/how-to-explain-dkim-in-plain-english-2/) signing -- [DMRAC](https://blog.returnpath.com/how-to-explain-dmarc-in-plain-english/) policy support +- [DMARC](https://blog.returnpath.com/how-to-explain-dmarc-in-plain-english/) policy support - Built-in [backscatter](https://en.wikipedia.org/wiki/Backscatter_(e-mail)) mitigation - Address aliases support - DANE support @@ -184,6 +185,15 @@ directives below. Note that it will require users to specify the full address as a username when logging in. +### DKIM & DMARC + +DMARC requires domain specified in From header to match domain used to make +the DKIM signature. This gets tricky with default configuration that signs +all messages with a single key. However, this is possible to configure +maddy to use different keys, it is just a bit verbose. +See [config.d/multitentant-dkim.conf](config.d/multitentant-dkim.conf) for +example of such configuration. + ## maddyctl utility To manage virtual users, mailboxes and messages maddyctl utility diff --git a/config.d/multitentant-dkim.conf b/config.d/multitentant-dkim.conf new file mode 100644 index 0000000..a331450 --- /dev/null +++ b/config.d/multitentant-dkim.conf @@ -0,0 +1,60 @@ +$(local_domains) = example.org example.com + +(submission_delivery) { + destination $(local_domains) { + deliver_to local_mailboxes + } + default_destination { + deliver_to remote_queue + } +} + +submission tls://0.0.0.0:465 { + auth local_authdb + + # Handle messages from example.org according to this block. + source example.org { + modify { + # Sign messages using example.org key with default selector. + sign_dkim example.org default + } + import submission_delivery + } + + # Handle messages from example.com according to this block. + source example.com { + modify { + # Sign messages using example.org key with default selector + sign_dkim example.com default + } + import submission_delivery + } + + # ... etc, duplicate this block for all domains you handle messages for ... + + default_source { + reject + } +} + +## These blocks are defined in default configuration +## we include them for completeness. + +sql local_mailboxes local_authdb { + driver sqlite3 + dsn example.db +} +queue remote_queue { + target remote + bounce { + destination $(local_domains) { + deliver_to local_mailboxes + } + default_destination { + reject 550 5.0.0 "Refusing to send DSNs to non-local addresses" + } + } +} +remote { + authenticate_mx mtasts dnssec +} diff --git a/go.mod b/go.mod index cef96d6..7b71278 100644 --- a/go.mod +++ b/go.mod @@ -28,3 +28,5 @@ require ( google.golang.org/appengine v1.6.2 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) + +replace github.com/emersion/go-msgauth => github.com/foxcpp/go-msgauth v0.2.1-0.20191026194926-ce292b9df55e diff --git a/go.sum b/go.sum index 89355ec..7bb2147 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/foxcpp/go-imap-sql v0.3.2-0.20191013180616-e44020418817 h1:Am+dCUvt/w github.com/foxcpp/go-imap-sql v0.3.2-0.20191013180616-e44020418817/go.mod h1:ooCDdmcUlEhvNl57ROzeZTpqJuoMP3HNd03kemcF4AE= github.com/foxcpp/go-imap-sql v0.3.2-0.20191013200128-9f708775d60a h1:bxaVAOKzjZwUguQ2n8TxZxJY41/Ii15seh8v+0OAUyM= github.com/foxcpp/go-imap-sql v0.3.2-0.20191013200128-9f708775d60a/go.mod h1:ooCDdmcUlEhvNl57ROzeZTpqJuoMP3HNd03kemcF4AE= +github.com/foxcpp/go-msgauth v0.2.1-0.20191026194926-ce292b9df55e h1:3BiGBbt1vAR25ycdDf+f7rAKmHHc32QIZJhR9FzTCC4= +github.com/foxcpp/go-msgauth v0.2.1-0.20191026194926-ce292b9df55e/go.mod h1:7r9HUSXL1dq+KK7Xqg0JlyBxNFGf5+JouRvSz4wBZCQ= github.com/frankban/quicktest v1.5.0/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= diff --git a/maddy.conf b/maddy.conf index 4bf9dcd..c786f1f 100644 --- a/maddy.conf +++ b/maddy.conf @@ -7,7 +7,9 @@ tls cert_file_path pkey_file # server. hostname mx1.example.org -# Primary domain is used as a sender of autogenerated messages. +# Primary domain is used as a sender of autogenerated messages and +# ADMD for DKIM signatures. You might want to change later when +# managing multiple domains. $(primary_domain) = example.org # All domains we want to receive messages for. @@ -70,6 +72,10 @@ submission tls://0.0.0.0:465 { # Use sql module for authentication. auth local_authdb + modify { + sign_dkim $(primary_domain) default + } + # All messages for the recipients at $(local_domains) should be # delivered to local mailboxes directly. destination $(local_domains) { diff --git a/maddy.go b/maddy.go index 4746320..ae522be 100644 --- a/maddy.go +++ b/maddy.go @@ -15,6 +15,7 @@ import ( _ "github.com/foxcpp/maddy/endpoint/imap" _ "github.com/foxcpp/maddy/endpoint/smtp" _ "github.com/foxcpp/maddy/modify" + _ "github.com/foxcpp/maddy/modify/dkim" _ "github.com/foxcpp/maddy/storage/sql" _ "github.com/foxcpp/maddy/target/queue" _ "github.com/foxcpp/maddy/target/remote" diff --git a/man/maddy-filters.5.scd b/man/maddy-filters.5.scd index ec86d89..fc43949 100644 --- a/man/maddy-filters.5.scd +++ b/man/maddy-filters.5.scd @@ -106,7 +106,7 @@ specified in EHLO/HELO command. By default, quarantines messages coming from servers with mismatched or missing PTR record, use 'fail_action' directive to change that. -# DKIM authorization module (verify_dkim) +# DKIM authentication module (verify_dkim) This is the check module that performs verification of the DKIM signatures present on the incoming messages. @@ -163,6 +163,144 @@ Action to take when there are not valid signatures in a message. Note that DMARC policy of the sender domain can request more strict handling of broken DKIM signatures. +# DKIM signing module (sign_dkim) + +sign_dkim module is a modifier that signs messages using DKIM +protocol (RFC 6376). + +``` +sign_dkim { + debug no + domain example.org + selector default + key_path dkim-keys/{domain}-{selector}.key + oversign_fields ... + sign_fields ... + header_canon relaxed + body_canon relaxed + sig_expiry 120h # 5 days + hash sha256 + newkey_algo rsa2048 +} +``` + +## Inline args + +When inline definitions are used, domain and selector can be specified +in arguments, so actual sign_dkim use can be shortened to the following: +``` +modify { + sign_dkim example.org selector +} +``` + +## Configuration directives + +*Syntax*: debug _boolean_ + +*Default*: global directive value + +Enable verbose logging for sign_dkim. + +*Syntax*: domain _string_ + +*Default*: not specified + +*REQUIRED.* + +ADministrative Management Domain (ADMD) taking responsibility for signed +messages. Should be specified either as a directive or as an inline +argument. + +*Syntax*: selector _string_ + +*Default*: not specified + +*REQUIRED.* + +Identifier of used key within the ADMD. +Should be specified either as a directive or as an inline argument. + +*Syntax*: key_path _string_ + +*Default*: dkim_keys/{domain}_{selector}.key + +Path to private key. It should be in PKCS#8 format wrapped in PAM encoding. +If key does not exist, it will be generated using algorithm specified +in newkey_algo. + +*Syntax*: oversign_fields _list..._ + +*Default*: see below + +Header fields that should be signed n+1 times where n is times they are +present in the message. This makes it impossible to replace field +value by prepending another field with the same name to the message. + +Fields specified here don't have to be also specified in sign_fields. + +Default set of oversigned fields: +- Subject +- To +- From +- Date +- MIME-Version +- Content-Type +- Content-Transfer-Encoding +- Reply-To +- Message-Id +- References +- Autocrypt +- Openpgp + +*Syntax*: sign_fields _list..._ + +*Default*: see below + +Header fields that should be signed n+1 times where n is times they are +present in the message. For these fields, additional values can be prepended +by intermediate relays, but existing values can't be changed. + +Default set of signed fields: +- List-Id +- List-Help +- List-Unsubscribe +- List-Post +- List-Owner +- List-Archive +- Resent-To +- Resent-Sender +- Resent-Message-Id +- Resent-Date +- Resent-From +- Resent-Cc + +*Syntax*: header_canon relaxed|simple + +*Default*: relaxed + +Canonicalization algorithm to use for header fields. With 'relaxed', whitespace within +fields can be modified without breaking the signature, with 'simple' no +modifications are allowed. + +*Syntax*: body_canon relaxed|simple + +*Default*: relaxed + +Canonicalization algorithm to use for message body. With 'relaxed', whitespace within +can be modified without breaking the signature, with 'simple' no +modifications are allowed. + +*Syntax*: sig_expiry _duration_ + +*Default*: 120h + +Time for which signature should be considered valid. Mainly used to prevent +unauthorized resending of old messages. + +*Syntax*: hash _hash_ + +*Default*: sha256 + +Hash algorithm to use when computing body hash. + +sha256 is the only supported algorithm now. + +*Syntax*: newkey_algo rsa4096|rsa2048|ed25519 + +*Default*: rsa2048 + +Algorithm to use when generating new key. + # Sender/recipient replacement modules (replace_sender, replace_rcpt) These are modules that simply replace matching address value(s) with another diff --git a/modify/dkim/dkim.go b/modify/dkim/dkim.go new file mode 100644 index 0000000..7adf304 --- /dev/null +++ b/modify/dkim/dkim.go @@ -0,0 +1,282 @@ +package dkim + +import ( + "crypto" + "errors" + "io" + "strings" + "time" + + "github.com/emersion/go-message/textproto" + "github.com/emersion/go-msgauth/dkim" + "github.com/foxcpp/maddy/buffer" + "github.com/foxcpp/maddy/config" + "github.com/foxcpp/maddy/log" + "github.com/foxcpp/maddy/module" + "github.com/foxcpp/maddy/target" +) + +const Day = 86400 * time.Second + +var ( + oversignDefault = []string{ + // Directly visible to the user. + "Subject", + "Sender", + "To", + "Cc", + "From", + "Date", + + // Affects body processing. + "MIME-Version", + "Content-Type", + "Content-Transfer-Encoding", + + // Affects user interaction. + "Reply-To", + "In-Reply-To", + "Message-Id", + "References", + + // Provide additional security benefit for OpenPGP. + "Autocrypt", + "Openpgp", + } + signDefault = []string{ + // Mailing list information. Not oversigned to prevent signature + // breakage by aliasing MLMs. + "List-Id", + "List-Help", + "List-Unsubscribe", + "List-Post", + "List-Owner", + "List-Archive", + + // Not oversigned since it can be prepended by intermediate relays. + "Resent-To", + "Resent-Sender", + "Resent-Message-Id", + "Resent-Date", + "Resent-From", + "Resent-Cc", + } + + hashFuncs = map[string]crypto.Hash{ + "sha256": crypto.SHA256, + } +) + +type Modifier struct { + instName string + + domain string + selector string + signer crypto.Signer + oversignHeader []string + signHeader []string + headerCanon dkim.Canonicalization + bodyCanon dkim.Canonicalization + sigExpiry time.Duration + hash crypto.Hash + + log log.Logger +} + +func New(_, instName string, _, inlineArgs []string) (module.Module, error) { + m := &Modifier{ + instName: instName, + log: log.Logger{Name: "sign_dkim"}, + } + + switch len(inlineArgs) { + case 2: + m.domain = inlineArgs[0] + m.selector = inlineArgs[1] + case 0: + // whatever + case 1: + fallthrough + default: + return nil, errors.New("sign_dkim: wrong amount of inline arguments") + } + + return m, nil +} + +func (m *Modifier) Name() string { + return "sign_dkim" +} + +func (m *Modifier) InstanceName() string { + return m.instName +} + +func (m *Modifier) Init(cfg *config.Map) error { + var ( + hashName string + keyPathTemplate string + newKeyAlgo string + ) + + cfg.Bool("debug", true, false, &m.log.Debug) + cfg.String("domain", false, false, m.domain, &m.domain) + cfg.String("selector", false, false, m.selector, &m.selector) + cfg.String("key_path", false, false, "dkim_keys/{domain}_{selector}.key", &keyPathTemplate) + cfg.StringList("oversign_fields", false, false, oversignDefault, &m.oversignHeader) + cfg.StringList("sign_fields", false, false, signDefault, &m.signHeader) + cfg.Enum("header_canon", false, false, + []string{string(dkim.CanonicalizationRelaxed), string(dkim.CanonicalizationSimple)}, + dkim.CanonicalizationRelaxed, (*string)(&m.headerCanon)) + cfg.Enum("body_canon", false, false, + []string{string(dkim.CanonicalizationRelaxed), string(dkim.CanonicalizationSimple)}, + dkim.CanonicalizationRelaxed, (*string)(&m.bodyCanon)) + cfg.Duration("sig_expiry", false, false, 5*Day, &m.sigExpiry) + cfg.Enum("hash", false, false, + []string{"sha256"}, "sha256", &hashName) + cfg.Enum("newkey_algo", false, false, + []string{"rsa4096", "rsa2048", "ed25519"}, "rsa2048", &newKeyAlgo) + + if _, err := cfg.Process(); err != nil { + return err + } + + if m.domain == "" { + return errors.New("sign_domain: domain is not specified") + } + if m.selector == "" { + return errors.New("sign_domain: selector is not specified") + } + + m.hash = hashFuncs[hashName] + if m.hash == 0 { + panic("sign_dkim.Init: Hash function allowed by config matcher but not present in hashFuncs") + } + + keyValues := strings.NewReplacer("{domain}", m.domain, "{selector}", m.selector) + keyPath := keyValues.Replace(keyPathTemplate) + + signer, err := m.loadOrGenerateKey(m.domain, m.selector, keyPath, newKeyAlgo) + if err != nil { + return err + } + m.signer = signer + + return nil +} + +func (m *Modifier) fieldsToSign(h textproto.Header) []string { + // Filter out duplicated fields from configs so they + // will not cause panic() in go-msgauth internals. + seen := make(map[string]struct{}) + + res := make([]string, 0, len(m.oversignHeader)+len(m.signHeader)) + for _, key := range m.oversignHeader { + if _, ok := seen[strings.ToLower(key)]; ok { + continue + } + seen[strings.ToLower(key)] = struct{}{} + + // Add to signing list once per each key use. + for field := h.FieldsByKey(key); field.Next(); { + res = append(res, key) + } + // And once more to "oversign" it. + res = append(res, key) + } + for _, key := range m.signHeader { + if _, ok := seen[strings.ToLower(key)]; ok { + continue + } + seen[strings.ToLower(key)] = struct{}{} + + // Add to signing list once per each key use. + for field := h.FieldsByKey(key); field.Next(); { + res = append(res, key) + } + } + return res +} + +type state struct { + m *Modifier + meta *module.MsgMetadata + log log.Logger +} + +func (m *Modifier) ModStateForMsg(msgMeta *module.MsgMetadata) (module.ModifierState, error) { + return state{ + m: m, + meta: msgMeta, + log: target.DeliveryLogger(m.log, msgMeta), + }, nil +} + +func (s state) RewriteSender(mailFrom string) (string, error) { + return mailFrom, nil +} + +func (s state) RewriteRcpt(rcptTo string) (string, error) { + return rcptTo, nil +} + +func (s state) RewriteBody(h textproto.Header, body buffer.Buffer) error { + id := s.meta.OriginalFrom + if !strings.Contains(id, "@") { + id += "@" + s.m.domain + } + + opts := dkim.SignOptions{ + Domain: s.m.domain, + Selector: s.m.selector, + Identifier: id, + Signer: s.m.signer, + Hash: s.m.hash, + HeaderCanonicalization: s.m.headerCanon, + BodyCanonicalization: s.m.bodyCanon, + HeaderKeys: s.m.fieldsToSign(h), + } + if s.m.sigExpiry != 0 { + opts.Expiration = time.Now().Add(s.m.sigExpiry) + } + signer, err := dkim.NewSigner(&opts) + if err != nil { + s.m.log.Printf("%v", strings.TrimPrefix(err.Error(), "dkim: ")) + return err + } + if err := textproto.WriteHeader(signer, h); err != nil { + s.m.log.Printf("I/O error: %v", err) + signer.Close() + return err + } + r, err := body.Open() + if err != nil { + s.m.log.Printf("I/O error: %v", err) + signer.Close() + return err + } + if _, err := io.Copy(signer, r); err != nil { + s.m.log.Printf("I/O error: %v", err) + signer.Close() + return err + } + + if err := signer.Close(); err != nil { + s.m.log.Printf("%v", strings.TrimPrefix(err.Error(), "dkim: ")) + return err + } + + h.Add("DKIM-Signature", signer.SignatureValue()) + + s.m.log.Debugf("signed, identifier = %s", id) + + return nil +} + +func (s state) Close() error { + return nil +} + +func init() { + module.Register("sign_dkim", New) +} diff --git a/modify/dkim/dkim_test.go b/modify/dkim/dkim_test.go new file mode 100644 index 0000000..e9ad8a1 --- /dev/null +++ b/modify/dkim/dkim_test.go @@ -0,0 +1,34 @@ +package dkim + +import ( + "reflect" + "sort" + "testing" + + "github.com/emersion/go-message/textproto" +) + +// TODO: Add tests that check actual signing once +// we have LookupTXT hook for go-msgauth/dkim. + +func TestFieldsToSign(t *testing.T) { + h := textproto.Header{} + h.Add("A", "1") + h.Add("c", "2") + h.Add("C", "3") + h.Add("a", "4") + h.Add("b", "5") + h.Add("unrelated", "6") + + m := Modifier{ + oversignHeader: []string{"A", "B"}, + signHeader: []string{"C"}, + } + fields := m.fieldsToSign(h) + sort.Strings(fields) + expected := []string{"A", "A", "A", "B", "B", "C", "C"} + + if !reflect.DeepEqual(fields, expected) { + t.Errorf("incorrect set of fields to sign\nwant: %v\ngot: %v", expected, fields) + } +} diff --git a/modify/dkim/keys.go b/modify/dkim/keys.go new file mode 100644 index 0000000..14ddf61 --- /dev/null +++ b/modify/dkim/keys.go @@ -0,0 +1,172 @@ +package dkim + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" +) + +func (m *Modifier) loadOrGenerateKey(expectedDomain, expectedSelector, keyPath, newKeyAlgo string) (crypto.Signer, error) { + // Notes: + // . PKCS #8 (RFC 5208) is a superset of PKCS #1 (RFC 3447). + // . DKIM records use ASN.1 DER (as in PKCS#1) wrapped in base64 for keys. + // . For ed25519, no additional encoding is used. + + f, err := os.Open(keyPath) + if err != nil { + if os.IsNotExist(err) { + return m.generateAndWrite(expectedDomain, expectedSelector, keyPath, newKeyAlgo) + } + return nil, err + } + defer f.Close() + + pemBlob, err := ioutil.ReadAll(f) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(pemBlob) + if block == nil { + return nil, fmt.Errorf("sign_dkim: %s: invalid PEM block", keyPath) + } + + if block.Type == "ENCRYPTED PRIVATE KEY" { + return nil, fmt.Errorf("sign_dkim: %s: encrypted, can't use", keyPath) + } + if block.Type != "PRIVATE KEY" && block.Type != "RSA PRIVATE KEY" { + return nil, fmt.Errorf("sign_dkim: %s: not a private key", keyPath) + } + + if val := block.Headers["X-DKIM-Selector"]; val != "" && val != expectedSelector { + return nil, fmt.Errorf("sign_dkim: %s: selector mismatch, want %s, got %s", keyPath, expectedSelector, val) + } + if val := block.Headers["X-DKIM-Domain"]; val != "" && val != expectedDomain { + return nil, fmt.Errorf("sign_dkim: %s: domain mismatch, want %s, got %s", keyPath, expectedDomain, val) + } + + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("sign_dkim: %s: %w", keyPath, err) + } + + switch key := key.(type) { + case *rsa.PrivateKey: + if err := key.Validate(); err != nil { + return nil, err + } + return key, nil + case ed25519.PrivateKey: + return key, nil + case *ecdsa.PublicKey: + return nil, fmt.Errorf("sign_dkim: %s: ECDSA keys are not supported", keyPath) + default: + return nil, fmt.Errorf("sign_dkim: %s: unknown key type: %T", keyPath, key) + } +} + +func (m *Modifier) generateAndWrite(domain, selector, keyPath, newKeyAlgo string) (crypto.Signer, error) { + wrapErr := func(err error) error { + return fmt.Errorf("sign_dkim: generate %s: %w", keyPath, err) + } + + m.log.Printf("generating a new %s keypair...", newKeyAlgo) + + var ( + pkey crypto.Signer + dkimName = newKeyAlgo + err error + ) + switch newKeyAlgo { + case "rsa4096": + dkimName = "rsa" + pkey, err = rsa.GenerateKey(rand.Reader, 4096) + case "rsa2048": + dkimName = "rsa" + pkey, err = rsa.GenerateKey(rand.Reader, 2048) + case "ed25519": + _, pkey, err = ed25519.GenerateKey(rand.Reader) + default: + err = fmt.Errorf("unknown key algorithm: %s", newKeyAlgo) + } + if err != nil { + return nil, wrapErr(err) + } + + keyBlob, err := x509.MarshalPKCS8PrivateKey(pkey) + if err != nil { + return nil, wrapErr(err) + } + + // 0777 because we have public keys in here too and they don't + // need protection. Individual private key files have 0600 perms. + if err := os.MkdirAll(filepath.Dir(keyPath), 0777); err != nil { + return nil, wrapErr(err) + } + + dnsPath, err := writeDNSRecord(keyPath, dkimName, pkey) + if err != nil { + return nil, wrapErr(err) + } + + f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return nil, wrapErr(err) + } + + if err := pem.Encode(f, &pem.Block{ + Type: "PRIVATE KEY", + Headers: map[string]string{ + "X-DKIM-Domain": domain, + "X-DKIM-Selector": selector, + }, + Bytes: keyBlob, + }); err != nil { + return nil, wrapErr(err) + } + + m.log.Printf("generated a new %s keypair, private key is in %s, TXT record with public key is in %s,\n"+ + "put its contents into TXT record for %s._domainkey.%s to make signing and verification work", + newKeyAlgo, keyPath, dnsPath, selector, domain) + + return pkey, nil +} + +func writeDNSRecord(keyPath, dkimAlgoName string, pkey crypto.Signer) (string, error) { + var ( + keyBlob []byte + pubkey = pkey.Public() + ) + switch pubkey := pubkey.(type) { + case *rsa.PublicKey: + keyBlob = x509.MarshalPKCS1PublicKey(pubkey) + case ed25519.PublicKey: + keyBlob = pubkey + default: + panic("sign_dkim.writeDNSRecord: unknown key algorithm") + } + + dnsPath := keyPath + ".dns" + if filepath.Ext(keyPath) == ".key" { + dnsPath = keyPath[:len(keyPath)-4] + ".dns" + } + dnsF, err := os.Create(dnsPath) + if err != nil { + return "", err + } + keyRecord := fmt.Sprintf("v=DKIM1; k=%s; p=%s", dkimAlgoName, base64.StdEncoding.EncodeToString(keyBlob)) + if _, err := io.WriteString(dnsF, keyRecord); err != nil { + return "", err + } + return dnsPath, nil +} diff --git a/modify/dkim/keys_test.go b/modify/dkim/keys_test.go new file mode 100644 index 0000000..4f6c1e2 --- /dev/null +++ b/modify/dkim/keys_test.go @@ -0,0 +1,88 @@ +package dkim + +import ( + "crypto/ed25519" + "encoding/base64" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/foxcpp/maddy/testutils" +) + +func TestKeyLoad_new(t *testing.T) { + m := Modifier{} + m.log = testutils.Logger(t, m.Name()) + + dir, err := ioutil.TempDir("", "maddy-tests-dkim-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + signer, err := m.loadOrGenerateKey("example.org", "default", filepath.Join(dir, "testkey.key"), "ed25519") + if err != nil { + t.Fatal(err) + } + + recordBlob, err := ioutil.ReadFile(filepath.Join(dir, "testkey.dns")) + if err != nil { + t.Fatal(err) + } + var keyBlob []byte + for _, part := range strings.Split(string(recordBlob), ";") { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "k=") { + if part != "k=ed25519" { + t.Fatalf("Wrong type of generated key, want ed25519, got %s", part) + } + } + if strings.HasPrefix(part, "p=") { + keyBlob, err = base64.StdEncoding.DecodeString(part[2:]) + if err != nil { + t.Fatal(err) + } + } + } + + blob := signer.Public().(ed25519.PublicKey) + if string(blob) != string(keyBlob) { + t.Fatal("wrong public key placed into record file") + } +} + +const pkeyEd25519 = `-----BEGIN PRIVATE KEY----- +X-DKIM-Domain: example.org +X-DKIM-Selector: default + +MC4CAQAwBQYDK2VwBCIEIJG9zs4vi2MYNkL9gUQwlmBLCzDODIJ5/1CwTAZFDm5U +-----END PRIVATE KEY-----` + +const pubkeyEd25519 = `5TPcCxzVByMyRsMFs5Dx23pnxKilI+1UrGg0t+O2oZU=` + +func TestKeyLoad_existing(t *testing.T) { + m := Modifier{} + m.log = testutils.Logger(t, m.Name()) + + dir, err := ioutil.TempDir("", "maddy-tests-dkim-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + if err := ioutil.WriteFile(filepath.Join(dir, "testkey.key"), []byte(pkeyEd25519), 0600); err != nil { + t.Fatal(err) + } + + signer, err := m.loadOrGenerateKey("example.org", "default", filepath.Join(dir, "testkey.key"), "ed25519") + if err != nil { + t.Fatal(err) + } + + blob := signer.Public().(ed25519.PublicKey) + if signerKey := base64.StdEncoding.EncodeToString(blob); signerKey != pubkeyEd25519 { + t.Fatalf("wrong public key returned by loadOrGenerateKey, \nwant %s\ngot %s", pubkeyEd25519, signerKey) + } +}