diff options
| author | Shulhan <ms@kilabit.info> | 2019-02-07 16:20:28 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2019-02-07 16:20:28 +0700 |
| commit | 2bcab0b7fccd5c8ab32dda77f49ff436ec7b5d6e (patch) | |
| tree | 8bb93aef7369f906fe6bc05c620ac96275c68c22 /lib/email/dkim | |
| parent | 8b79d1e11e165093b3c1b64a4cefc0a898fc5012 (diff) | |
| download | pakakeh.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.go | 17 | ||||
| -rw-r--r-- | lib/email/dkim/hashalg.go | 64 | ||||
| -rw-r--r-- | lib/email/dkim/key.go | 239 | ||||
| -rw-r--r-- | lib/email/dkim/key_test.go | 44 | ||||
| -rw-r--r-- | lib/email/dkim/keyflag.go | 62 | ||||
| -rw-r--r-- | lib/email/dkim/keytype.go | 39 | ||||
| -rw-r--r-- | lib/email/dkim/signature.go | 20 | ||||
| -rw-r--r-- | lib/email/dkim/tag.go | 58 |
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"), } // |
