aboutsummaryrefslogtreecommitdiff
path: root/lib/email/dkim
diff options
context:
space:
mode:
authorShulhan <ms@kilabit.info>2019-02-07 16:20:28 +0700
committerShulhan <ms@kilabit.info>2019-02-07 16:20:28 +0700
commit2bcab0b7fccd5c8ab32dda77f49ff436ec7b5d6e (patch)
tree8bb93aef7369f906fe6bc05c620ac96275c68c22 /lib/email/dkim
parent8b79d1e11e165093b3c1b64a4cefc0a898fc5012 (diff)
downloadpakakeh.go-2bcab0b7fccd5c8ab32dda77f49ff436ec7b5d6e.tar.xz
email/dkim: implement public key lookup with DNS
This implementation is based on RFC 6376 section 3.6 Key Management and Representation.
Diffstat (limited to 'lib/email/dkim')
-rw-r--r--lib/email/dkim/dkim.go17
-rw-r--r--lib/email/dkim/hashalg.go64
-rw-r--r--lib/email/dkim/key.go239
-rw-r--r--lib/email/dkim/key_test.go44
-rw-r--r--lib/email/dkim/keyflag.go62
-rw-r--r--lib/email/dkim/keytype.go39
-rw-r--r--lib/email/dkim/signature.go20
-rw-r--r--lib/email/dkim/tag.go58
8 files changed, 515 insertions, 28 deletions
diff --git a/lib/email/dkim/dkim.go b/lib/email/dkim/dkim.go
new file mode 100644
index 00000000..b1a63b59
--- /dev/null
+++ b/lib/email/dkim/dkim.go
@@ -0,0 +1,17 @@
+// Copyright 2019, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package dkim
+
+//
+// DefaultNameServers contains list of nameserver's IP addresses.
+// If its not empty, the public key lookup using DNS/TXT will use this values.
+//
+var DefaultNameServers []string // nolint: gochecknoglobals
+
+var ( // nolint: gochecknoglobals
+ sepColon = []byte{':'}
+ sepVBar = []byte{'|'}
+ dkimSubdomain = []byte("_domainkey")
+)
diff --git a/lib/email/dkim/hashalg.go b/lib/email/dkim/hashalg.go
new file mode 100644
index 00000000..da7f4310
--- /dev/null
+++ b/lib/email/dkim/hashalg.go
@@ -0,0 +1,64 @@
+// Copyright 2019, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package dkim
+
+import (
+ "bytes"
+)
+
+//
+// HashAlg define the type for hash algorithm.
+//
+type HashAlg byte
+
+//
+// List of valid and known hash algorithms.
+//
+const (
+ HashAlgALL HashAlg = 0 // (default to allow all)
+ HashAlgSHA256 = 1 << iota // sha256
+ HashAlgSHA1 // sha1
+)
+
+//
+// hashAlgNames contains mapping between type value and their names.
+//
+var hashAlgNames = map[HashAlg][]byte{ // nolint: gochecknoglobals
+ HashAlgSHA256: []byte("sha256"),
+ HashAlgSHA1: []byte("sha1"),
+}
+
+func unpackHashAlgs(v []byte) (hashAlgs []HashAlg) {
+ algs := bytes.Split(v, sepColon)
+ for x := 0; x < len(algs); x++ {
+ for k, v := range hashAlgNames {
+ if bytes.Equal(v, algs[x]) {
+ hashAlgs = append(hashAlgs, k)
+ break
+ }
+ }
+ }
+ if len(hashAlgs) == 0 {
+ hashAlgs = append(hashAlgs, HashAlgALL)
+ }
+
+ return
+}
+
+func packHashAlgs(hashAlgs []HashAlg) []byte {
+ var bb bytes.Buffer
+
+ for _, v := range hashAlgs {
+ if v == HashAlgALL {
+ return nil
+ }
+ if bb.Len() > 0 {
+ bb.Write(sepColon)
+ }
+ bb.Write(hashAlgNames[v])
+ }
+
+ return bb.Bytes()
+}
diff --git a/lib/email/dkim/key.go b/lib/email/dkim/key.go
new file mode 100644
index 00000000..9b503b3c
--- /dev/null
+++ b/lib/email/dkim/key.go
@@ -0,0 +1,239 @@
+// Copyright 2019, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package dkim
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/shuLhan/share/lib/dns"
+)
+
+var dnsClientPool *dns.UDPClientPool // nolint: gochecknoglobals
+
+//
+// Key represent a DKIM key record.
+//
+type Key struct {
+ // REQUIRED fields.
+
+ // Public contains public key data.
+ // ("p=", base64, REQUIRED)
+ Public []byte
+
+ // RECOMMENDED fields.
+
+ // Version of DKIM key record.
+ // ("v=", plain-text, RECOMMENDED, default is "DKIM1")
+ Version []byte
+
+ // OPTIONAL fields.
+
+ // Type of key.
+ // ("k=", plain-text, OPTIONAL, default is "rsa").
+ Type KeyType
+
+ // HashAlgs contains list of hash algorithm that might be used.
+ // ("h=", plain-text colon-separated, OPTIONAL, defaults to allowing
+ // all algorithms)
+ HashAlgs []HashAlg
+
+ // Notes that might be interest to human.
+ // ("n=", qp-section, OPTIONAL, default is empty)
+ Notes []byte
+
+ // Services contains list of service types to which this record
+ // applies.
+ // ("s=", plain-text colon-separated, OPTIONAL, default is "*").
+ Services [][]byte
+
+ // Flags contains list of flags.
+ // ("t=", plain-text colon-separated, OPTIONAL, default is no flags set)
+ Flags []KeyFlag
+}
+
+//
+// LookupKey DKIM (public) key using specific query method, domain name
+// (SDID), and selector.
+// The sdid MUST NOT be empty, but the selector MAY be empty.
+//
+func LookupKey(qmethod QueryMethod, sdid, selector []byte) (key *Key, err error) {
+ if len(sdid) == 0 {
+ return nil, fmt.Errorf("dkim: LookupKey: empty SDID")
+ }
+ if qmethod.Type == QueryTypeDNS {
+ key, err = lookupDNS(qmethod.Option, sdid, selector)
+ }
+ return key, err
+}
+
+//
+// ParseTXT parse DNS TXT resource record into Key.
+//
+func ParseTXT(txt []byte) (key *Key, err error) {
+ p := newParser(txt)
+
+ key = &Key{}
+ for {
+ tag, err := p.fetchTag()
+ if err != nil {
+ return nil, err
+ }
+ if tag == nil {
+ break
+ }
+ err = key.set(tag)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return key, nil
+}
+
+func lookupDNS(opt QueryOption, sdid, selector []byte) (key *Key, err error) {
+ if opt == QueryOptionTXT {
+ key, err = lookupDNSTXT(sdid, selector)
+ }
+ return key, err
+}
+
+func lookupDNSTXT(sdid, selector []byte) (key *Key, err error) {
+ if dnsClientPool == nil {
+ err = newDNSClientPool()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ dnsClient := dnsClientPool.Get()
+
+ var bb bytes.Buffer
+ if len(selector) > 0 {
+ bb.Write(selector)
+ bb.WriteByte('.')
+ }
+ bb.Write(dkimSubdomain)
+ bb.WriteByte('.')
+ bb.Write(sdid)
+
+ qname := bb.Bytes()
+ dnsMsg, err := dnsClient.Lookup(dns.QueryTypeTXT, dns.QueryClassIN, qname)
+ if err != nil {
+ dnsClientPool.Put(dnsClient)
+ return nil, fmt.Errorf("dkim: LookupKey: " + err.Error())
+ }
+ if len(dnsMsg.Answer) == 0 {
+ dnsClientPool.Put(dnsClient)
+ return nil, fmt.Errorf("dkim: LookupKey: empty answer on '%s'", qname)
+ }
+
+ dnsClientPool.Put(dnsClient)
+
+ answers := dnsMsg.FilterAnswers(dns.QueryTypeTXT)
+ if len(answers) == 0 {
+ return nil, fmt.Errorf("dkim: LookupKey: no TXT record on '%s'", qname)
+ }
+ if len(answers) != 1 {
+ return nil, fmt.Errorf("dkim: LookupKey: multiple TXT record on '%s'", qname)
+ }
+
+ txt := answers[0].RData().([]byte)
+
+ return ParseTXT(txt)
+}
+
+func newDNSClientPool() (err error) {
+ var ns []string
+
+ if len(DefaultNameServers) > 0 {
+ ns = DefaultNameServers
+ } else {
+ ns = dns.GetSystemNameServers("")
+ if len(ns) == 0 {
+ ns = append(ns, "1.1.1.1")
+ }
+ }
+
+ dnsClientPool, err = dns.NewUDPClientPool(ns)
+
+ return err
+}
+
+//
+// Bytes return text representation of Key.
+//
+func (key *Key) Bytes() []byte {
+ var bb bytes.Buffer
+
+ if len(key.Version) > 0 {
+ bb.WriteString("v=")
+ bb.Write(key.Version)
+ bb.WriteByte(';')
+ }
+ if len(key.Public) > 0 {
+ if bb.Len() > 0 {
+ bb.WriteByte(' ')
+ }
+ bb.WriteString("p=")
+ bb.Write(key.Public)
+ bb.WriteByte(';')
+ }
+
+ bb.WriteString(" k=")
+ bb.Write(keyTypeNames[key.Type])
+ bb.WriteByte(';')
+
+ if len(key.HashAlgs) > 0 {
+ bb.WriteString(" h=")
+ bb.Write(packHashAlgs(key.HashAlgs))
+ bb.WriteByte(';')
+ }
+ if len(key.Notes) > 0 {
+ bb.WriteString(" n=")
+ bb.Write(key.Notes)
+ bb.WriteByte(';')
+ }
+ if len(key.Services) > 0 {
+ bb.WriteString(" s=")
+ bb.Write(bytes.Join(key.Services, sepColon))
+ bb.WriteByte(';')
+ }
+ if len(key.Flags) > 0 {
+ bb.WriteString(" t=")
+ bb.Write(packKeyFlags(key.Flags))
+ }
+
+ return bb.Bytes()
+}
+
+func (key *Key) set(t *tag) (err error) {
+ if t == nil {
+ return nil
+ }
+ switch t.key {
+ case tagDNSPublicKey:
+ key.Public = t.value
+
+ case tagDNSVersion:
+ key.Version = t.value
+
+ case tagDNSHashAlgs:
+ key.HashAlgs = unpackHashAlgs(t.value)
+
+ case tagDNSKeyType:
+ key.Type = parseKeyType(t.value)
+
+ case tagDNSNotes:
+ key.Notes = t.value
+
+ case tagDNSServices:
+ key.Services = bytes.Split(t.value, sepColon)
+
+ case tagDNSFlags:
+ key.Flags = unpackKeyFlags(t.value)
+ }
+ return err
+}
diff --git a/lib/email/dkim/key_test.go b/lib/email/dkim/key_test.go
new file mode 100644
index 00000000..9e0d7455
--- /dev/null
+++ b/lib/email/dkim/key_test.go
@@ -0,0 +1,44 @@
+// Copyright 2019, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package dkim
+
+import (
+ "testing"
+
+ "github.com/shuLhan/share/lib/test"
+)
+
+// nolint: lll
+func TestLookupKey(t *testing.T) {
+ qmethod := QueryMethod{}
+
+ cases := []struct {
+ desc string
+ sdid string
+ selector string
+ exp string
+ expErr string
+ }{{
+ desc: "With empty input",
+ expErr: "dkim: LookupKey: empty SDID",
+ }, {
+ desc: "With empty selector",
+ sdid: "amazonses.com",
+ selector: "ug7nbtf4gccmlpwj322ax3p6ow6yfsug",
+ exp: "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCKkjP6XucgQ06cVZ89Ue/sQDu4v1/AJVd6mMK4bS2YmXk5PzWw4KWtWNUZlg77hegAChx1pG85lUbJ+x4awp28VXqRi3/jZoC6W+3ELysDvVohZPMRMadc+KVtyTiTH4BL38/8ZV9zkj4ZIaaYyiLAiYX+c3+lZQEF3rKDptRcpwIDAQAB; k=rsa;",
+ }}
+
+ for _, c := range cases {
+ t.Log(c.desc)
+
+ got, err := LookupKey(qmethod, []byte(c.sdid), []byte(c.selector))
+ if err != nil {
+ test.Assert(t, "error", c.expErr, err.Error(), true)
+ continue
+ }
+
+ test.Assert(t, "Key", c.exp, string(got.Bytes()), true)
+ }
+}
diff --git a/lib/email/dkim/keyflag.go b/lib/email/dkim/keyflag.go
new file mode 100644
index 00000000..082f4470
--- /dev/null
+++ b/lib/email/dkim/keyflag.go
@@ -0,0 +1,62 @@
+// Copyright 2019, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package dkim
+
+import (
+ "bytes"
+)
+
+//
+// KeyFlag define a type of key flag in DKIM key record.
+//
+type KeyFlag byte
+
+//
+// List of valid key flags.
+//
+const (
+ // KeyFlagTesting or "y" in text, indicate that domain is for testing
+ // DKIM.
+ KeyFlagTesting KeyFlag = iota
+
+ // KeyFlagStrict or "s" in text, means that the domain in AUID ("i=")
+ // tag value MUST equal or subdomain of SDID "d=" tag value.
+ KeyFlagStrict
+)
+
+//
+// keyFlagNames contains mapping between key flag and their text
+// representation.
+//
+var keyFlagNames = map[KeyFlag]byte{ // nolint: gochecknoglobals
+ KeyFlagTesting: 'y',
+ KeyFlagStrict: 's',
+}
+
+func unpackKeyFlags(in []byte) (out []KeyFlag) {
+ flags := bytes.Split(in, sepColon)
+ for x := 0; x < len(flags); x++ {
+ for k, v := range keyFlagNames {
+ if flags[x][0] == v {
+ out = append(out, k)
+ break
+ }
+ }
+ }
+ return out
+}
+
+func packKeyFlags(flags []KeyFlag) []byte {
+ var bb bytes.Buffer
+
+ for _, flag := range flags {
+ if bb.Len() > 0 {
+ bb.Write(sepColon)
+ }
+ bb.WriteByte(keyFlagNames[flag])
+ }
+
+ return bb.Bytes()
+}
diff --git a/lib/email/dkim/keytype.go b/lib/email/dkim/keytype.go
new file mode 100644
index 00000000..ee1d7f9e
--- /dev/null
+++ b/lib/email/dkim/keytype.go
@@ -0,0 +1,39 @@
+// Copyright 2019, Shulhan <ms@kilabit.info>. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package dkim
+
+import (
+ "bytes"
+)
+
+//
+// KeyType define a type of algorithm that sign the key.
+//
+type KeyType byte
+
+//
+// List of valid key types.
+//
+const (
+ KeyTypeRSA KeyType = iota // "rsa" (default)
+)
+
+//
+// keyTypeNames contains mapping between key type and their text
+// representation.
+//
+var keyTypeNames = map[KeyType][]byte{ // nolint: gochecknoglobals
+ KeyTypeRSA: []byte("rsa"),
+}
+
+func parseKeyType(in []byte) (t KeyType) {
+ for k, name := range keyTypeNames {
+ if bytes.Equal(in, name) {
+ return k
+ }
+ }
+ // I know that this is unnecessary, but for readibility.
+ return KeyTypeRSA
+}
diff --git a/lib/email/dkim/signature.go b/lib/email/dkim/signature.go
index e71e2193..b3c54d4e 100644
--- a/lib/email/dkim/signature.go
+++ b/lib/email/dkim/signature.go
@@ -12,12 +12,6 @@ import (
"time"
)
-var (
- sepHeaders = []byte{':'}
- sepMethods = []byte{':'}
- sepPresentHeaders = []byte{'|'}
-)
-
//
// Signature represents the value of DKIM-Signature header field tag.
//
@@ -149,7 +143,7 @@ func (sig *Signature) Relaxed() []byte {
_, _ = fmt.Fprintf(&bb, "v=%s; a=%s; d=%s; s=%s;\r\n\t"+
"h=%s;\r\n\tbh=%s;\r\n\tb=%s;\r\n\t",
sig.Version, signAlgNames[*sig.Alg], sig.SDID, sig.Selector,
- bytes.Join(sig.Headers, sepHeaders), sig.BodyHash, sig.Value)
+ bytes.Join(sig.Headers, sepColon), sig.BodyHash, sig.Value)
if sig.CreatedAt > 0 {
_, _ = fmt.Fprintf(&bb, "t=%d; ", sig.CreatedAt)
@@ -233,7 +227,6 @@ func (sig *Signature) Verify() (err error) {
found := false
for x := 0; x < len(sig.Headers); x++ {
- fmt.Printf("h[%d]=%s\n", x, sig.Headers[x])
if bytes.EqualFold(sig.Headers[x], []byte("from")) {
found = true
break
@@ -265,7 +258,6 @@ func (sig *Signature) Verify() (err error) {
if len(bb) != 2 {
return fmt.Errorf("dkim: missing AUID domain: '%s'", sig.AUID)
}
- fmt.Printf("AUID domain: %s\n", bb[1])
if !bytes.HasSuffix(bb[1], sig.SDID) {
return fmt.Errorf("dkim: invalid AUID: '%s'", sig.AUID)
}
@@ -291,6 +283,7 @@ func (sig *Signature) set(t *tag) (err error) {
case tagAlg:
for k, name := range signAlgNames {
if bytes.Equal(t.value, name) {
+ k := k
sig.Alg = &k
return nil
}
@@ -301,7 +294,7 @@ func (sig *Signature) set(t *tag) (err error) {
sig.SDID = t.value
case tagHeaders:
- sig.Headers = bytes.Split(t.value, sepHeaders)
+ sig.Headers = bytes.Split(t.value, sepColon)
case tagSelector:
sig.Selector = t.value
@@ -322,7 +315,7 @@ func (sig *Signature) set(t *tag) (err error) {
err = sig.setCanons(t.value)
case tagPresentHeaders:
- sig.PresentHeaders = bytes.Split(t.value, sepPresentHeaders)
+ sig.PresentHeaders = bytes.Split(t.value, sepVBar)
case tagAUID:
sig.AUID = t.value
@@ -357,7 +350,7 @@ func (sig *Signature) setCanons(v []byte) (err error) {
canonHeader = canons[0]
canonBody = canons[1]
default:
- err = fmt.Errorf("dkim: invalid canonicalization: '%s'", v)
+ return fmt.Errorf("dkim: invalid canonicalization: '%s'", v)
}
t, err := parseCanonValue(canonHeader)
@@ -388,6 +381,7 @@ func parseCanonValue(v []byte) (*Canon, error) {
}
for k, cname := range canonNames {
if bytes.Equal(v, cname) {
+ k := k
return &k, nil
}
}
@@ -399,7 +393,7 @@ func parseCanonValue(v []byte) (*Canon, error) {
// based on first match.
//
func (sig *Signature) setQueryMethods(v []byte) {
- methods := bytes.Split(v, sepMethods)
+ methods := bytes.Split(v, sepColon)
for _, m := range methods {
var qtype, qopt []byte
diff --git a/lib/email/dkim/tag.go b/lib/email/dkim/tag.go
index ddf3afcc..3120aa06 100644
--- a/lib/email/dkim/tag.go
+++ b/lib/email/dkim/tag.go
@@ -18,29 +18,53 @@ type tagKey int
//
const (
tagUnknown tagKey = 0
+
+ // Tags in DKIM-Signature field value, ordered by priority.
+
+ // Required tags.
+ tagVersion tagKey = 1 << iota // v=
+ tagAlg // a=
+ tagSDID // d=
+ tagSelector // s=
+ tagHeaders // h=
+ tagBodyHash // bh=
+ tagSignature // b=
+ // Recommended tags.
+ tagCreatedAt // t=
+ tagExpiredAt // x=
+ // Optional tags.
+ tagCanon // c=
+ tagPresentHeaders // z=
+ tagAUID // i=
+ tagBodyLength // l=
+ tagQueryMethod // q=
+
+ // Tags in DNS TXT record.
+
// Required tags.
- tagVersion tagKey = 1 << iota
- tagAlg
- tagSDID
- tagSelector
- tagHeaders
- tagBodyHash
- tagSignature
+ tagDNSPublicKey // p=
+ // Optional tags.
+ tagDNSKeyType // k=
+ tagDNSNotes // n=
+)
+
+//
+// Mapping between tag in DKIM-Signature and tag in DKIM domain record,
+// since both have the same text representation.
+//
+const (
// Recommended tags.
- tagCreatedAt
- tagExpiredAt
+ tagDNSVersion tagKey = tagVersion // v=
// Optional tags.
- tagCanon
- tagPresentHeaders
- tagAUID
- tagBodyLength
- tagQueryMethod
+ tagDNSHashAlgs = tagHeaders // h=
+ tagDNSServices = tagSelector // s=
+ tagDNSFlags = tagCreatedAt // t=
)
//
// Mapping between tag key in numeric and their human readable form.
//
-var tagKeys = map[tagKey][]byte{
+var tagKeys = map[tagKey][]byte{ // nolint: gochecknoglobals
tagVersion: []byte("v"),
tagAlg: []byte("a"),
tagSDID: []byte("d"),
@@ -55,6 +79,10 @@ var tagKeys = map[tagKey][]byte{
tagAUID: []byte("i"),
tagBodyLength: []byte("l"),
tagQueryMethod: []byte("q"),
+
+ tagDNSPublicKey: []byte("p"),
+ tagDNSKeyType: []byte("k"),
+ tagDNSNotes: []byte("n"),
}
//