├── watch ├── .gitignore ├── LICENSE ├── README.md ├── dkimHeader_test.go ├── errors.go ├── pubKeyRep.go ├── pubKeyRep_test.go ├── dkim.go ├── dkimHeader.go └── dkim_test.go /watch: -------------------------------------------------------------------------------- 1 | while true 2 | do 3 | inotifywait -q -r -e modify,attrib,close_write,move,create,delete . && echo "--------------" && go test -v 4 | done -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Stéphane Depierrepont 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-dkim 2 | DKIM package for Golang 3 | 4 | [![GoDoc](https://godoc.org/github.com/toorop/go-dkim?status.svg)](https://godoc.org/github.com/toorop/go-dkim) 5 | 6 | ## Getting started 7 | 8 | ### Install 9 | ``` 10 | go get github.com/toorop/go-dkim 11 | ``` 12 | Warning: you need to use Go 1.4.2-master or 1.4.3 (when it will be available) 13 | see https://github.com/golang/go/issues/10482 fro more info. 14 | 15 | ### Sign email 16 | 17 | ```go 18 | import ( 19 | dkim "github.com/toorop/go-dkim" 20 | ) 21 | 22 | func main(){ 23 | // email is the email to sign (byte slice) 24 | // privateKey the private key (pem encoded, byte slice ) 25 | options := dkim.NewSigOptions() 26 | options.PrivateKey = privateKey 27 | options.Domain = "mydomain.tld" 28 | options.Selector = "myselector" 29 | options.SignatureExpireIn = 3600 30 | options.BodyLength = 50 31 | options.Headers = []string{"from", "date", "mime-version", "received", "received"} 32 | options.AddSignatureTimestamp = true 33 | options.Canonicalization = "relaxed/relaxed" 34 | err := dkim.Sign(&email, options) 35 | // handle err.. 36 | 37 | // And... that's it, 'email' is signed ! Amazing© !!! 38 | } 39 | ``` 40 | 41 | ### Verify 42 | ```go 43 | import ( 44 | dkim "github.com/toorop/go-dkim" 45 | ) 46 | 47 | func main(){ 48 | // email is the email to verify (byte slice) 49 | status, err := Verify(&email) 50 | // handle status, err (see godoc for status) 51 | } 52 | ``` 53 | 54 | ## Todo 55 | 56 | - [ ] handle z tag (copied header fields used for diagnostic use) 57 | -------------------------------------------------------------------------------- /dkimHeader_test.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-test/deep" 7 | ) 8 | 9 | func Test_GetHeader(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input string 13 | want *DKIMHeader 14 | wantErr bool 15 | }{ 16 | { 17 | name: "Signed relaxed with length", 18 | input: signedRelaxedRelaxedLength, 19 | want: &DKIMHeader{ 20 | Version: "1", 21 | Algorithm: "rsa-sha256", 22 | QueryMethods: []string{"dns/txt"}, 23 | MessageCanonicalization: "relaxed/relaxed", 24 | Selector: "test", 25 | Domain: "tmail.io", 26 | Auid: "@tmail.io", 27 | BodyLength: 5, 28 | Headers: []string{"from", "date", "mime-version", "received", "received"}, 29 | BodyHash: "GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=", 30 | SignatureData: "byhiFWd0lAM1sqD1tl8S1DZtKNqgiEZp8jrGds6RRydnZkdX9rCPeL0Q5MYWBQ/JmQrml5" + 31 | "pIghLwl/EshDBmNy65O6qO8pSSGgZmM3T7SRLMloex8bnrBJ4KSYcHV46639gVEWcBOKW0" + 32 | "h1djZu2jaTuxGeJzlFVtw3Arf2B93cc=", 33 | }, 34 | }, 35 | { 36 | name: "No signature", 37 | input: bodySimple, 38 | wantErr: true, 39 | }, 40 | } 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | email := []byte(tt.input) 44 | got, err := GetHeader(&email) 45 | if (err != nil) != tt.wantErr { 46 | t.Errorf("GetHeader() error = %v, wantErr %v", err, tt.wantErr) 47 | return 48 | } 49 | if diff := deep.Equal(tt.want, got); diff != nil { 50 | t.Error(diff) 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // ErrSignPrivateKeyRequired when there not private key in config 9 | ErrSignPrivateKeyRequired = errors.New("PrivateKey is required") 10 | 11 | // ErrSignDomainRequired when there is no domain defined in config 12 | ErrSignDomainRequired = errors.New("Domain is required") 13 | 14 | // ErrSignSelectorRequired when there is no Selcteir defined in config 15 | ErrSignSelectorRequired = errors.New("Selector is required") 16 | 17 | // ErrSignHeaderShouldContainsFrom If Headers is specified it should at least contain 'from' 18 | ErrSignHeaderShouldContainsFrom = errors.New("header must contains 'from' field") 19 | 20 | // ErrSignBadCanonicalization If bad Canonicalization parameter 21 | ErrSignBadCanonicalization = errors.New("bad Canonicalization parameter") 22 | 23 | // ErrCandNotParsePrivateKey when unable to parse private key 24 | ErrCandNotParsePrivateKey = errors.New("can not parse private key, check format (pem) and validity") 25 | 26 | // ErrSignBadAlgo Bad algorithm 27 | ErrSignBadAlgo = errors.New("bad algorithm. Only rsa-sha1 or rsa-sha256 are permitted") 28 | 29 | // ErrBadMailFormat unable to parse mail 30 | ErrBadMailFormat = errors.New("bad mail format") 31 | 32 | // ErrBadMailFormatHeaders bad headers format (not DKIM Header) 33 | ErrBadMailFormatHeaders = errors.New("bad mail format found in headers") 34 | 35 | // ErrBadDKimTagLBodyTooShort bad l tag 36 | ErrBadDKimTagLBodyTooShort = errors.New("bad tag l or bodyLength option. Body length < l value") 37 | 38 | // ErrDkimHeaderBadFormat when errors found in DKIM header 39 | ErrDkimHeaderBadFormat = errors.New("bad DKIM header format") 40 | 41 | // ErrDkimHeaderNotFound when there's no DKIM-Signature header in an email we have to verify 42 | ErrDkimHeaderNotFound = errors.New("no DKIM-Signature header field found ") 43 | 44 | // ErrDkimHeaderBTagNotFound when there's no b tag 45 | ErrDkimHeaderBTagNotFound = errors.New("no tag 'b' found in dkim header") 46 | 47 | // ErrDkimHeaderNoFromInHTag when from is missing in h tag 48 | ErrDkimHeaderNoFromInHTag = errors.New("'from' header is missing in h tag") 49 | 50 | // ErrDkimHeaderMissingRequiredTag when a required tag is missing 51 | ErrDkimHeaderMissingRequiredTag = errors.New("signature missing required tag") 52 | 53 | // ErrDkimHeaderDomainMismatch if i tag is not a sub domain of d tag 54 | ErrDkimHeaderDomainMismatch = errors.New("domain mismatch") 55 | 56 | // ErrDkimVersionNotsupported version not supported 57 | ErrDkimVersionNotsupported = errors.New("incompatible version") 58 | 59 | // Query method unsupported 60 | errQueryMethodNotsupported = errors.New("query method not supported") 61 | 62 | // ErrVerifyBodyHash when body hash doesn't verify 63 | ErrVerifyBodyHash = errors.New("body hash did not verify") 64 | 65 | // ErrVerifyNoKeyForSignature no key 66 | ErrVerifyNoKeyForSignature = errors.New("no key for verify") 67 | 68 | // ErrVerifyKeyUnavailable when service (dns) is anavailable 69 | ErrVerifyKeyUnavailable = errors.New("key unavailable") 70 | 71 | // ErrVerifyTagVMustBeTheFirst if present the v tag must be the firts in the record 72 | ErrVerifyTagVMustBeTheFirst = errors.New("pub key syntax error: v tag must be the first") 73 | 74 | // ErrVerifyVersionMusBeDkim1 if présent flag v (version) must be DKIM1 75 | ErrVerifyVersionMusBeDkim1 = errors.New("flag v must be set to DKIM1") 76 | 77 | // ErrVerifyBadKeyType bad type for pub key (only rsa is accepted) 78 | ErrVerifyBadKeyType = errors.New("bad type for key type") 79 | 80 | // ErrVerifyRevokedKey key(s) for this selector is revoked (p is empty) 81 | ErrVerifyRevokedKey = errors.New("revoked key") 82 | 83 | // ErrVerifyBadKey when we can't parse pubkey 84 | ErrVerifyBadKey = errors.New("unable to parse pub key") 85 | 86 | // ErrVerifyNoKey when no key is found on DNS record 87 | ErrVerifyNoKey = errors.New("no public key found in DNS TXT") 88 | 89 | // ErrVerifySignatureHasExpired when signature has expired 90 | ErrVerifySignatureHasExpired = errors.New("signature has expired") 91 | 92 | // ErrVerifyInappropriateHashAlgo when h tag in pub key doesn't contain hash algo from a tag of DKIM header 93 | ErrVerifyInappropriateHashAlgo = errors.New("inappropriate has algorithm") 94 | ) 95 | -------------------------------------------------------------------------------- /pubKeyRep.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "crypto/rsa" 5 | "crypto/x509" 6 | "encoding/base64" 7 | "io/ioutil" 8 | "mime/quotedprintable" 9 | "net" 10 | "strings" 11 | ) 12 | 13 | // PubKeyRep represents a parsed version of public key record 14 | type PubKeyRep struct { 15 | Version string 16 | HashAlgo []string 17 | KeyType string 18 | Note string 19 | PubKey rsa.PublicKey 20 | ServiceType []string 21 | FlagTesting bool // flag y 22 | FlagIMustBeD bool // flag i 23 | } 24 | 25 | // DNSOptions holds settings for looking up DNS records 26 | type DNSOptions struct { 27 | netLookupTXT func(name string) ([]string, error) 28 | } 29 | 30 | // DNSOpt represents an optional setting for looking up DNS records 31 | type DNSOpt interface { 32 | apply(*DNSOptions) 33 | } 34 | 35 | type dnsOpt func(*DNSOptions) 36 | 37 | func (opt dnsOpt) apply(dnsOpts *DNSOptions) { 38 | opt(dnsOpts) 39 | } 40 | 41 | // DNSOptLookupTXT sets the function to use to lookup TXT records. 42 | // 43 | // This should probably only be used in tests. 44 | func DNSOptLookupTXT(netLookupTXT func(name string) ([]string, error)) DNSOpt { 45 | return dnsOpt(func(opts *DNSOptions) { 46 | opts.netLookupTXT = netLookupTXT 47 | }) 48 | } 49 | 50 | // NewPubKeyRespFromDNS retrieves the TXT record from DNS based on the specified domain and selector 51 | // and parses it. 52 | func NewPubKeyRespFromDNS(selector, domain string, opts ...DNSOpt) (*PubKeyRep, verifyOutput, error) { 53 | dnsOpts := DNSOptions{} 54 | 55 | for _, opt := range opts { 56 | opt.apply(&dnsOpts) 57 | } 58 | 59 | if dnsOpts.netLookupTXT == nil { 60 | dnsOpts.netLookupTXT = net.LookupTXT 61 | } 62 | 63 | txt, err := dnsOpts.netLookupTXT(selector + "._domainkey." + domain) 64 | if err != nil { 65 | if strings.HasSuffix(err.Error(), "no such host") { 66 | return nil, PERMFAIL, ErrVerifyNoKeyForSignature 67 | } 68 | 69 | return nil, TEMPFAIL, ErrVerifyKeyUnavailable 70 | } 71 | 72 | // empty record 73 | if len(txt) == 0 { 74 | return nil, PERMFAIL, ErrVerifyNoKeyForSignature 75 | } 76 | 77 | // parsing, we keep the first record 78 | // TODO: if there is multiple record 79 | 80 | return NewPubKeyResp(txt[0]) 81 | } 82 | 83 | // NewPubKeyResp parses DKIM record (usually from DNS) 84 | func NewPubKeyResp(dkimRecord string) (*PubKeyRep, verifyOutput, error) { 85 | pkr := new(PubKeyRep) 86 | pkr.Version = "DKIM1" 87 | pkr.HashAlgo = []string{"sha1", "sha256"} 88 | pkr.KeyType = "rsa" 89 | pkr.FlagTesting = false 90 | pkr.FlagIMustBeD = false 91 | 92 | p := strings.Split(dkimRecord, ";") 93 | for i, data := range p { 94 | keyVal := strings.SplitN(data, "=", 2) 95 | val := "" 96 | if len(keyVal) > 1 { 97 | val = strings.TrimSpace(keyVal[1]) 98 | } 99 | switch strings.ToLower(strings.TrimSpace(keyVal[0])) { 100 | case "v": 101 | // RFC: is this tag is specified it MUST be the first in the record 102 | if i != 0 { 103 | return nil, PERMFAIL, ErrVerifyTagVMustBeTheFirst 104 | } 105 | pkr.Version = val 106 | if pkr.Version != "DKIM1" { 107 | return nil, PERMFAIL, ErrVerifyVersionMusBeDkim1 108 | } 109 | case "h": 110 | p := strings.Split(strings.ToLower(val), ":") 111 | pkr.HashAlgo = []string{} 112 | for _, h := range p { 113 | h = strings.TrimSpace(h) 114 | if h == "sha1" || h == "sha256" { 115 | pkr.HashAlgo = append(pkr.HashAlgo, h) 116 | } 117 | } 118 | // if empty switch back to default 119 | if len(pkr.HashAlgo) == 0 { 120 | pkr.HashAlgo = []string{"sha1", "sha256"} 121 | } 122 | case "k": 123 | if strings.ToLower(val) != "rsa" { 124 | return nil, PERMFAIL, ErrVerifyBadKeyType 125 | } 126 | case "n": 127 | qp, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(val))) 128 | if err == nil { 129 | val = string(qp) 130 | } 131 | pkr.Note = val 132 | case "p": 133 | rawkey := val 134 | if rawkey == "" { 135 | return nil, PERMFAIL, ErrVerifyRevokedKey 136 | } 137 | un64, err := base64.StdEncoding.DecodeString(rawkey) 138 | if err != nil { 139 | return nil, PERMFAIL, ErrVerifyBadKey 140 | } 141 | pk, err := x509.ParsePKIXPublicKey(un64) 142 | if pk, ok := pk.(*rsa.PublicKey); ok { 143 | pkr.PubKey = *pk 144 | } 145 | case "s": 146 | t := strings.Split(strings.ToLower(val), ":") 147 | for _, tt := range t { 148 | tt = strings.TrimSpace(tt) 149 | switch tt { 150 | case "*": 151 | pkr.ServiceType = append(pkr.ServiceType, "all") 152 | case "email": 153 | pkr.ServiceType = append(pkr.ServiceType, tt) 154 | } 155 | } 156 | case "t": 157 | flags := strings.Split(strings.ToLower(val), ":") 158 | for _, flag := range flags { 159 | flag = strings.TrimSpace(flag) 160 | switch flag { 161 | case "y": 162 | pkr.FlagTesting = true 163 | case "s": 164 | pkr.FlagIMustBeD = true 165 | } 166 | } 167 | } 168 | } 169 | 170 | // if no pubkey 171 | if pkr.PubKey == (rsa.PublicKey{}) { 172 | return nil, PERMFAIL, ErrVerifyNoKey 173 | } 174 | 175 | // No service type 176 | if len(pkr.ServiceType) == 0 { 177 | pkr.ServiceType = []string{"all"} 178 | } 179 | 180 | return pkr, SUCCESS, nil 181 | } 182 | -------------------------------------------------------------------------------- /pubKeyRep_test.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPubKeyRep(t *testing.T) { 10 | t.Parallel() 11 | 12 | type testCase struct { 13 | Name string 14 | Txt string 15 | Expect *PubKeyRep 16 | VerifyOutput verifyOutput 17 | Err error 18 | } 19 | 20 | testCases := []testCase{ 21 | { 22 | Name: "only required", 23 | Txt: "p=" + pubKey, 24 | Expect: &PubKeyRep{ 25 | Version: "DKIM1", 26 | HashAlgo: []string{"sha1", "sha256"}, 27 | KeyType: "rsa", 28 | ServiceType: []string{"all"}, 29 | PubKey: privKeyRSA(t).PublicKey, 30 | }, 31 | VerifyOutput: SUCCESS, 32 | }, 33 | { 34 | Name: "empty record", 35 | Txt: "", 36 | VerifyOutput: PERMFAIL, 37 | Err: ErrVerifyNoKey, 38 | }, 39 | 40 | // v= 41 | { 42 | Name: "version not first", 43 | Txt: "p=" + pubKey + "; v=DKIM1", 44 | VerifyOutput: PERMFAIL, 45 | Err: ErrVerifyTagVMustBeTheFirst, 46 | }, 47 | { 48 | Name: "wrong version", 49 | Txt: "v=DKIM2; p=" + pubKey, 50 | VerifyOutput: PERMFAIL, 51 | Err: ErrVerifyVersionMusBeDkim1, 52 | }, 53 | 54 | // p= 55 | { 56 | Name: "no key", 57 | Txt: "v=DKIM1", 58 | VerifyOutput: PERMFAIL, 59 | Err: ErrVerifyNoKey, 60 | }, 61 | { 62 | Name: "key revoked", 63 | Txt: "v=DKIM1; p=", 64 | VerifyOutput: PERMFAIL, 65 | Err: ErrVerifyRevokedKey, 66 | }, 67 | { 68 | Name: "key invalid", 69 | Txt: "v=DKIM1; p=badBase64", 70 | VerifyOutput: PERMFAIL, 71 | Err: ErrVerifyBadKey, 72 | }, 73 | 74 | // h= 75 | { 76 | Name: "all supported hashes", 77 | Txt: "v=DKIM1; h=sha1:sha256; p=" + pubKey, 78 | Expect: &PubKeyRep{ 79 | Version: "DKIM1", 80 | HashAlgo: []string{"sha1", "sha256"}, 81 | KeyType: "rsa", 82 | ServiceType: []string{"all"}, 83 | PubKey: privKeyRSA(t).PublicKey, 84 | }, 85 | VerifyOutput: SUCCESS, 86 | }, 87 | { 88 | Name: "sha256 only", 89 | Txt: "v=DKIM1; h=sha256; p=" + pubKey, 90 | Expect: &PubKeyRep{ 91 | Version: "DKIM1", 92 | HashAlgo: []string{"sha256"}, 93 | KeyType: "rsa", 94 | ServiceType: []string{"all"}, 95 | PubKey: privKeyRSA(t).PublicKey, 96 | }, 97 | VerifyOutput: SUCCESS, 98 | }, 99 | { 100 | Name: "sha1 only", 101 | Txt: "v=DKIM1; h=sha1; p=" + pubKey, 102 | Expect: &PubKeyRep{ 103 | Version: "DKIM1", 104 | HashAlgo: []string{"sha1"}, 105 | KeyType: "rsa", 106 | ServiceType: []string{"all"}, 107 | PubKey: privKeyRSA(t).PublicKey, 108 | }, 109 | VerifyOutput: SUCCESS, 110 | }, 111 | { 112 | Name: "unsupported hash", 113 | Txt: "v=DKIM1; h=sha512; p=" + pubKey, 114 | Expect: &PubKeyRep{ 115 | Version: "DKIM1", 116 | HashAlgo: []string{"sha1", "sha256"}, 117 | KeyType: "rsa", 118 | ServiceType: []string{"all"}, 119 | PubKey: privKeyRSA(t).PublicKey, 120 | }, 121 | VerifyOutput: SUCCESS, 122 | }, 123 | { 124 | Name: "unsupported hash with supported hash", 125 | Txt: "v=DKIM1; h=sha256:sha512; p=" + pubKey, 126 | Expect: &PubKeyRep{ 127 | Version: "DKIM1", 128 | HashAlgo: []string{"sha256"}, 129 | KeyType: "rsa", 130 | ServiceType: []string{"all"}, 131 | PubKey: privKeyRSA(t).PublicKey, 132 | }, 133 | VerifyOutput: SUCCESS, 134 | }, 135 | { 136 | Name: "empty hash list", 137 | Txt: "v=DKIM1; h=; p=" + pubKey, 138 | Expect: &PubKeyRep{ 139 | Version: "DKIM1", 140 | HashAlgo: []string{"sha1", "sha256"}, 141 | KeyType: "rsa", 142 | ServiceType: []string{"all"}, 143 | PubKey: privKeyRSA(t).PublicKey, 144 | }, 145 | VerifyOutput: SUCCESS, 146 | }, 147 | 148 | // k= 149 | { 150 | Name: "key type rsa", 151 | Txt: "v=DKIM1; k=rsa; p=" + pubKey, 152 | Expect: &PubKeyRep{ 153 | Version: "DKIM1", 154 | HashAlgo: []string{"sha1", "sha256"}, 155 | KeyType: "rsa", 156 | ServiceType: []string{"all"}, 157 | PubKey: privKeyRSA(t).PublicKey, 158 | }, 159 | VerifyOutput: SUCCESS, 160 | }, 161 | { 162 | Name: "unsupported key type", 163 | Txt: "v=DKIM1; k=dsa; p=" + pubKey, 164 | VerifyOutput: PERMFAIL, 165 | Err: ErrVerifyBadKeyType, 166 | }, 167 | { 168 | Name: "empty key type", 169 | Txt: "v=DKIM1; k=; p=" + pubKey, 170 | VerifyOutput: PERMFAIL, 171 | Err: ErrVerifyBadKeyType, 172 | }, 173 | 174 | // n= 175 | { 176 | Name: "with note", 177 | Txt: "v=DKIM1; n=a note; p=" + pubKey, 178 | Expect: &PubKeyRep{ 179 | Version: "DKIM1", 180 | HashAlgo: []string{"sha1", "sha256"}, 181 | KeyType: "rsa", 182 | ServiceType: []string{"all"}, 183 | Note: "a note", 184 | PubKey: privKeyRSA(t).PublicKey, 185 | }, 186 | VerifyOutput: SUCCESS, 187 | }, 188 | { 189 | Name: "with note (qp)", 190 | Txt: "v=DKIM1; n=a note=3B encoded as quoted printable; p=" + pubKey, 191 | Expect: &PubKeyRep{ 192 | Version: "DKIM1", 193 | HashAlgo: []string{"sha1", "sha256"}, 194 | KeyType: "rsa", 195 | ServiceType: []string{"all"}, 196 | Note: "a note; encoded as quoted printable", 197 | PubKey: privKeyRSA(t).PublicKey, 198 | }, 199 | VerifyOutput: SUCCESS, 200 | }, 201 | { 202 | Name: "with note (bad qp)", 203 | Txt: "v=DKIM1; n=a note =! with invalid quoted printable; p=" + pubKey, 204 | Expect: &PubKeyRep{ 205 | Version: "DKIM1", 206 | HashAlgo: []string{"sha1", "sha256"}, 207 | KeyType: "rsa", 208 | ServiceType: []string{"all"}, 209 | Note: "a note =! with invalid quoted printable", 210 | PubKey: privKeyRSA(t).PublicKey, 211 | }, 212 | VerifyOutput: SUCCESS, 213 | }, 214 | { 215 | Name: "empty note", 216 | Txt: "v=DKIM1; n=; p=" + pubKey, 217 | Expect: &PubKeyRep{ 218 | Version: "DKIM1", 219 | HashAlgo: []string{"sha1", "sha256"}, 220 | KeyType: "rsa", 221 | ServiceType: []string{"all"}, 222 | PubKey: privKeyRSA(t).PublicKey, 223 | }, 224 | VerifyOutput: SUCCESS, 225 | }, 226 | 227 | // s= 228 | { 229 | Name: "any service", 230 | Txt: "v=DKIM1; s=*; p=" + pubKey, 231 | Expect: &PubKeyRep{ 232 | Version: "DKIM1", 233 | HashAlgo: []string{"sha1", "sha256"}, 234 | KeyType: "rsa", 235 | ServiceType: []string{"all"}, 236 | PubKey: privKeyRSA(t).PublicKey, 237 | }, 238 | VerifyOutput: SUCCESS, 239 | }, 240 | { 241 | Name: "email service", 242 | Txt: "v=DKIM1; s=email; p=" + pubKey, 243 | Expect: &PubKeyRep{ 244 | Version: "DKIM1", 245 | HashAlgo: []string{"sha1", "sha256"}, 246 | KeyType: "rsa", 247 | ServiceType: []string{"email"}, 248 | PubKey: privKeyRSA(t).PublicKey, 249 | }, 250 | VerifyOutput: SUCCESS, 251 | }, 252 | { 253 | Name: "all services", 254 | Txt: "v=DKIM1; s=* : email; p=" + pubKey, 255 | Expect: &PubKeyRep{ 256 | Version: "DKIM1", 257 | HashAlgo: []string{"sha1", "sha256"}, 258 | KeyType: "rsa", 259 | ServiceType: []string{"all", "email"}, 260 | PubKey: privKeyRSA(t).PublicKey, 261 | }, 262 | VerifyOutput: SUCCESS, 263 | }, 264 | { 265 | Name: "unsupported service", 266 | Txt: "v=DKIM1; s=unknown; p=" + pubKey, 267 | Expect: &PubKeyRep{ 268 | Version: "DKIM1", 269 | HashAlgo: []string{"sha1", "sha256"}, 270 | KeyType: "rsa", 271 | ServiceType: []string{"all"}, 272 | PubKey: privKeyRSA(t).PublicKey, 273 | }, 274 | VerifyOutput: SUCCESS, 275 | }, 276 | { 277 | Name: "unsupported service with supported service", 278 | Txt: "v=DKIM1; s=unknown:email; p=" + pubKey, 279 | Expect: &PubKeyRep{ 280 | Version: "DKIM1", 281 | HashAlgo: []string{"sha1", "sha256"}, 282 | KeyType: "rsa", 283 | ServiceType: []string{"email"}, 284 | PubKey: privKeyRSA(t).PublicKey, 285 | }, 286 | VerifyOutput: SUCCESS, 287 | }, 288 | { 289 | Name: "empty services", 290 | Txt: "v=DKIM1; s=; p=" + pubKey, 291 | Expect: &PubKeyRep{ 292 | Version: "DKIM1", 293 | HashAlgo: []string{"sha1", "sha256"}, 294 | KeyType: "rsa", 295 | ServiceType: []string{"all"}, 296 | PubKey: privKeyRSA(t).PublicKey, 297 | }, 298 | VerifyOutput: SUCCESS, 299 | }, 300 | 301 | // t= 302 | { 303 | Name: "testing mode", 304 | Txt: "v=DKIM1; t=y; p=" + pubKey, 305 | Expect: &PubKeyRep{ 306 | Version: "DKIM1", 307 | HashAlgo: []string{"sha1", "sha256"}, 308 | KeyType: "rsa", 309 | ServiceType: []string{"all"}, 310 | PubKey: privKeyRSA(t).PublicKey, 311 | FlagTesting: true, 312 | }, 313 | VerifyOutput: SUCCESS, 314 | }, 315 | { 316 | Name: "strict mode", 317 | Txt: "v=DKIM1; t=s; p=" + pubKey, 318 | Expect: &PubKeyRep{ 319 | Version: "DKIM1", 320 | HashAlgo: []string{"sha1", "sha256"}, 321 | KeyType: "rsa", 322 | ServiceType: []string{"all"}, 323 | PubKey: privKeyRSA(t).PublicKey, 324 | FlagIMustBeD: true, 325 | }, 326 | VerifyOutput: SUCCESS, 327 | }, 328 | { 329 | Name: "both test flags", 330 | Txt: "v=DKIM1; t=y : s; p=" + pubKey, 331 | Expect: &PubKeyRep{ 332 | Version: "DKIM1", 333 | HashAlgo: []string{"sha1", "sha256"}, 334 | KeyType: "rsa", 335 | ServiceType: []string{"all"}, 336 | PubKey: privKeyRSA(t).PublicKey, 337 | FlagTesting: true, 338 | FlagIMustBeD: true, 339 | }, 340 | VerifyOutput: SUCCESS, 341 | }, 342 | { 343 | Name: "include invalid test flag", 344 | Txt: "v=DKIM1; t=y:s:?; p=" + pubKey, 345 | Expect: &PubKeyRep{ 346 | Version: "DKIM1", 347 | HashAlgo: []string{"sha1", "sha256"}, 348 | KeyType: "rsa", 349 | ServiceType: []string{"all"}, 350 | PubKey: privKeyRSA(t).PublicKey, 351 | FlagTesting: true, 352 | FlagIMustBeD: true, 353 | }, 354 | VerifyOutput: SUCCESS, 355 | }, 356 | { 357 | Name: "invalid test flag", 358 | Txt: "v=DKIM1; t=?; p=" + pubKey, 359 | Expect: &PubKeyRep{ 360 | Version: "DKIM1", 361 | HashAlgo: []string{"sha1", "sha256"}, 362 | KeyType: "rsa", 363 | ServiceType: []string{"all"}, 364 | PubKey: privKeyRSA(t).PublicKey, 365 | }, 366 | VerifyOutput: SUCCESS, 367 | }, 368 | { 369 | Name: "empty test flags", 370 | Txt: "v=DKIM1; t=; p=" + pubKey, 371 | Expect: &PubKeyRep{ 372 | Version: "DKIM1", 373 | HashAlgo: []string{"sha1", "sha256"}, 374 | KeyType: "rsa", 375 | ServiceType: []string{"all"}, 376 | PubKey: privKeyRSA(t).PublicKey, 377 | }, 378 | VerifyOutput: SUCCESS, 379 | }, 380 | } 381 | 382 | for _, tc := range testCases { 383 | // Subtests are actually run in goroutines, so make sure to capture the loop var 384 | tc := tc 385 | t.Run(tc.Name, func(t *testing.T) { 386 | pubKeyRep, vo, err := NewPubKeyResp(tc.Txt) 387 | if tc.Err != nil { 388 | assert.EqualError(t, err, tc.Err.Error()) 389 | } else { 390 | assert.NoError(t, err) 391 | } 392 | 393 | assert.Equal(t, tc.VerifyOutput, vo) 394 | assert.EqualValues(t, tc.Expect, pubKeyRep) 395 | }) 396 | } 397 | } 398 | -------------------------------------------------------------------------------- /dkim.go: -------------------------------------------------------------------------------- 1 | // Package dkim provides tools for signing and verify a email according to RFC 6376 2 | package dkim 3 | 4 | import ( 5 | "bytes" 6 | "container/list" 7 | "crypto" 8 | "crypto/rand" 9 | "crypto/rsa" 10 | "crypto/sha1" 11 | "crypto/sha256" 12 | "crypto/x509" 13 | "encoding/base64" 14 | "encoding/pem" 15 | "hash" 16 | "regexp" 17 | "strings" 18 | "time" 19 | ) 20 | 21 | const ( 22 | CRLF = "\r\n" 23 | TAB = " " 24 | FWS = CRLF + TAB 25 | MaxHeaderLineLength = 70 26 | ) 27 | 28 | type verifyOutput int 29 | 30 | const ( 31 | SUCCESS verifyOutput = 1 + iota 32 | PERMFAIL 33 | TEMPFAIL 34 | NOTSIGNED 35 | TESTINGSUCCESS 36 | TESTINGPERMFAIL 37 | TESTINGTEMPFAIL 38 | ) 39 | 40 | // sigOptions represents signing options 41 | type SigOptions struct { 42 | // DKIM version (default 1) 43 | Version uint 44 | 45 | // Private key used for signing (required) 46 | PrivateKey []byte 47 | 48 | // Domain (required) 49 | Domain string 50 | 51 | // Selector (required) 52 | Selector string 53 | 54 | // The Agent of User IDentifier 55 | Auid string 56 | 57 | // Message canonicalization (plain-text; OPTIONAL, default is 58 | // "simple/simple"). This tag informs the Verifier of the type of 59 | // canonicalization used to prepare the message for signing. 60 | Canonicalization string 61 | 62 | // The algorithm used to generate the signature 63 | //"rsa-sha1" or "rsa-sha256" 64 | Algo string 65 | 66 | // Signed header fields 67 | Headers []string 68 | 69 | // Body length count( if set to 0 this tag is ommited in Dkim header) 70 | BodyLength uint 71 | 72 | // Query Methods used to retrieve the public key 73 | QueryMethods []string 74 | 75 | // Add a signature timestamp 76 | AddSignatureTimestamp bool 77 | 78 | // Time validity of the signature (0=never) 79 | SignatureExpireIn uint64 80 | 81 | // CopiedHeaderFileds 82 | CopiedHeaderFields []string 83 | } 84 | 85 | // NewSigOptions returns new sigoption with some defaults value 86 | func NewSigOptions() SigOptions { 87 | return SigOptions{ 88 | Version: 1, 89 | Canonicalization: "simple/simple", 90 | Algo: "rsa-sha256", 91 | Headers: []string{"from"}, 92 | BodyLength: 0, 93 | QueryMethods: []string{"dns/txt"}, 94 | AddSignatureTimestamp: true, 95 | SignatureExpireIn: 0, 96 | } 97 | } 98 | 99 | // Sign signs an email 100 | func Sign(email *[]byte, options SigOptions) error { 101 | var privateKey *rsa.PrivateKey 102 | var err error 103 | 104 | // PrivateKey 105 | if len(options.PrivateKey) == 0 { 106 | return ErrSignPrivateKeyRequired 107 | } 108 | d, _ := pem.Decode(options.PrivateKey) 109 | if d == nil { 110 | return ErrCandNotParsePrivateKey 111 | } 112 | 113 | // try to parse it as PKCS1 otherwise try PKCS8 114 | if key, err := x509.ParsePKCS1PrivateKey(d.Bytes); err != nil { 115 | if key, err := x509.ParsePKCS8PrivateKey(d.Bytes); err != nil { 116 | return ErrCandNotParsePrivateKey 117 | } else { 118 | privateKey = key.(*rsa.PrivateKey) 119 | } 120 | } else { 121 | privateKey = key 122 | } 123 | 124 | // Domain required 125 | if options.Domain == "" { 126 | return ErrSignDomainRequired 127 | } 128 | 129 | // Selector required 130 | if options.Selector == "" { 131 | return ErrSignSelectorRequired 132 | } 133 | 134 | // Canonicalization 135 | options.Canonicalization, err = validateCanonicalization(strings.ToLower(options.Canonicalization)) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | // Algo 141 | options.Algo = strings.ToLower(options.Algo) 142 | if options.Algo != "rsa-sha1" && options.Algo != "rsa-sha256" { 143 | return ErrSignBadAlgo 144 | } 145 | 146 | // Header must contain "from" 147 | hasFrom := false 148 | for i, h := range options.Headers { 149 | h = strings.ToLower(h) 150 | options.Headers[i] = h 151 | if h == "from" { 152 | hasFrom = true 153 | } 154 | } 155 | if !hasFrom { 156 | return ErrSignHeaderShouldContainsFrom 157 | } 158 | 159 | // Normalize 160 | headers, body, err := canonicalize(email, options.Canonicalization, options.Headers) 161 | if err != nil { 162 | return err 163 | } 164 | 165 | signHash := strings.Split(options.Algo, "-") 166 | 167 | // hash body 168 | bodyHash, err := getBodyHash(&body, signHash[1], options.BodyLength) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | // Get dkim header base 174 | dkimHeader := newDkimHeaderBySigOptions(options) 175 | dHeader := dkimHeader.getHeaderBaseForSigning(bodyHash) 176 | 177 | canonicalizations := strings.Split(options.Canonicalization, "/") 178 | dHeaderCanonicalized, err := canonicalizeHeader(dHeader, canonicalizations[0]) 179 | if err != nil { 180 | return err 181 | } 182 | headers = append(headers, []byte(dHeaderCanonicalized)...) 183 | headers = bytes.TrimRight(headers, " \r\n") 184 | 185 | // sign 186 | sig, err := getSignature(&headers, privateKey, signHash[1]) 187 | 188 | // add to DKIM-Header 189 | subh := "" 190 | l := len(subh) 191 | for _, c := range sig { 192 | subh += string(c) 193 | l++ 194 | if l >= MaxHeaderLineLength { 195 | dHeader += subh + FWS 196 | subh = "" 197 | l = 0 198 | } 199 | } 200 | dHeader += subh + CRLF 201 | *email = append([]byte(dHeader), *email...) 202 | return nil 203 | } 204 | 205 | // Verify verifies an email an return 206 | // state: SUCCESS or PERMFAIL or TEMPFAIL, TESTINGSUCCESS, TESTINGPERMFAIL 207 | // TESTINGTEMPFAIL or NOTSIGNED 208 | // error: if an error occurs during verification 209 | func Verify(email *[]byte, opts ...DNSOpt) (verifyOutput, error) { 210 | // parse email 211 | dkimHeader, err := GetHeader(email) 212 | if err != nil { 213 | if err == ErrDkimHeaderNotFound { 214 | return NOTSIGNED, ErrDkimHeaderNotFound 215 | } 216 | return PERMFAIL, err 217 | } 218 | 219 | // we do not set query method because if it's others, validation failed earlier 220 | pubKey, verifyOutputOnError, err := NewPubKeyRespFromDNS(dkimHeader.Selector, dkimHeader.Domain, opts...) 221 | if err != nil { 222 | // fix https://github.com/toorop/go-dkim/issues/1 223 | // return getVerifyOutput(verifyOutputOnError, err, pubKey.FlagTesting) 224 | return verifyOutputOnError, err 225 | } 226 | 227 | // Normalize 228 | headers, body, err := canonicalize(email, dkimHeader.MessageCanonicalization, dkimHeader.Headers) 229 | if err != nil { 230 | return getVerifyOutput(PERMFAIL, err, pubKey.FlagTesting) 231 | } 232 | sigHash := strings.Split(dkimHeader.Algorithm, "-") 233 | // check if hash algo are compatible 234 | compatible := false 235 | for _, algo := range pubKey.HashAlgo { 236 | if sigHash[1] == algo { 237 | compatible = true 238 | break 239 | } 240 | } 241 | if !compatible { 242 | return getVerifyOutput(PERMFAIL, ErrVerifyInappropriateHashAlgo, pubKey.FlagTesting) 243 | } 244 | 245 | // expired ? 246 | if !dkimHeader.SignatureExpiration.IsZero() && dkimHeader.SignatureExpiration.Before(time.Now()) { 247 | return getVerifyOutput(PERMFAIL, ErrVerifySignatureHasExpired, pubKey.FlagTesting) 248 | } 249 | 250 | // println("|" + string(body) + "|") 251 | // get body hash 252 | bodyHash, err := getBodyHash(&body, sigHash[1], dkimHeader.BodyLength) 253 | if err != nil { 254 | return getVerifyOutput(PERMFAIL, err, pubKey.FlagTesting) 255 | } 256 | // println(bodyHash) 257 | if bodyHash != dkimHeader.BodyHash { 258 | return getVerifyOutput(PERMFAIL, ErrVerifyBodyHash, pubKey.FlagTesting) 259 | } 260 | 261 | // compute sig 262 | dkimHeaderCano, err := canonicalizeHeader(dkimHeader.rawForSign, strings.Split(dkimHeader.MessageCanonicalization, "/")[0]) 263 | if err != nil { 264 | return getVerifyOutput(TEMPFAIL, err, pubKey.FlagTesting) 265 | } 266 | toSignStr := string(headers) + dkimHeaderCano 267 | toSign := bytes.TrimRight([]byte(toSignStr), " \r\n") 268 | 269 | err = verifySignature(toSign, dkimHeader.SignatureData, &pubKey.PubKey, sigHash[1]) 270 | if err != nil { 271 | return getVerifyOutput(PERMFAIL, err, pubKey.FlagTesting) 272 | } 273 | return SUCCESS, nil 274 | } 275 | 276 | // getVerifyOutput returns output of verify fct according to the testing flag 277 | func getVerifyOutput(status verifyOutput, err error, flagTesting bool) (verifyOutput, error) { 278 | if !flagTesting { 279 | return status, err 280 | } 281 | switch status { 282 | case SUCCESS: 283 | return TESTINGSUCCESS, err 284 | case PERMFAIL: 285 | return TESTINGPERMFAIL, err 286 | case TEMPFAIL: 287 | return TESTINGTEMPFAIL, err 288 | } 289 | // should never happen but compilator sream whithout return 290 | return status, err 291 | } 292 | 293 | // canonicalize returns canonicalized version of header and body 294 | func canonicalize(email *[]byte, cano string, h []string) (headers, body []byte, err error) { 295 | body = []byte{} 296 | rxReduceWS := regexp.MustCompile(`[ \t]+`) 297 | 298 | rawHeaders, rawBody, err := getHeadersBody(email) 299 | if err != nil { 300 | return nil, nil, err 301 | } 302 | 303 | canonicalizations := strings.Split(cano, "/") 304 | 305 | // canonicalyze header 306 | headersList, err := getHeadersList(&rawHeaders) 307 | 308 | // pour chaque header a conserver on traverse tous les headers dispo 309 | // If multi instance of a field we must keep it from the bottom to the top 310 | var match *list.Element 311 | headersToKeepList := list.New() 312 | 313 | for _, headerToKeep := range h { 314 | match = nil 315 | headerToKeepToLower := strings.ToLower(headerToKeep) 316 | for e := headersList.Front(); e != nil; e = e.Next() { 317 | // fmt.Printf("|%s|\n", e.Value.(string)) 318 | t := strings.Split(e.Value.(string), ":") 319 | if strings.ToLower(t[0]) == headerToKeepToLower { 320 | match = e 321 | } 322 | } 323 | if match != nil { 324 | headersToKeepList.PushBack(match.Value.(string) + "\r\n") 325 | headersList.Remove(match) 326 | } 327 | } 328 | 329 | // if canonicalizations[0] == "simple" { 330 | for e := headersToKeepList.Front(); e != nil; e = e.Next() { 331 | cHeader, err := canonicalizeHeader(e.Value.(string), canonicalizations[0]) 332 | if err != nil { 333 | return headers, body, err 334 | } 335 | headers = append(headers, []byte(cHeader)...) 336 | } 337 | // canonicalyze body 338 | if canonicalizations[1] == "simple" { 339 | // simple 340 | // The "simple" body canonicalization algorithm ignores all empty lines 341 | // at the end of the message body. An empty line is a line of zero 342 | // length after removal of the line terminator. If there is no body or 343 | // no trailing CRLF on the message body, a CRLF is added. It makes no 344 | // other changes to the message body. In more formal terms, the 345 | // "simple" body canonicalization algorithm converts "*CRLF" at the end 346 | // of the body to a single "CRLF". 347 | // Note that a completely empty or missing body is canonicalized as a 348 | // single "CRLF"; that is, the canonicalized length will be 2 octets. 349 | body = bytes.TrimRight(rawBody, "\r\n") 350 | body = append(body, []byte{13, 10}...) 351 | } else { 352 | // relaxed 353 | // Ignore all whitespace at the end of lines. Implementations 354 | // MUST NOT remove the CRLF at the end of the line. 355 | // Reduce all sequences of WSP within a line to a single SP 356 | // character. 357 | // Ignore all empty lines at the end of the message body. "Empty 358 | // line" is defined in Section 3.4.3. If the body is non-empty but 359 | // does not end with a CRLF, a CRLF is added. (For email, this is 360 | // only possible when using extensions to SMTP or non-SMTP transport 361 | // mechanisms.) 362 | rawBody = rxReduceWS.ReplaceAll(rawBody, []byte(" ")) 363 | for _, line := range bytes.SplitAfter(rawBody, []byte{10}) { 364 | line = bytes.TrimRight(line, " \r\n") 365 | body = append(body, line...) 366 | body = append(body, []byte{13, 10}...) 367 | } 368 | body = bytes.TrimRight(body, "\r\n") 369 | body = append(body, []byte{13, 10}...) 370 | 371 | } 372 | return 373 | } 374 | 375 | // canonicalizeHeader returns canonicalized version of header 376 | func canonicalizeHeader(header string, algo string) (string, error) { 377 | // rxReduceWS := regexp.MustCompile(`[ \t]+`) 378 | if algo == "simple" { 379 | // The "simple" header canonicalization algorithm does not change header 380 | // fields in any way. Header fields MUST be presented to the signing or 381 | // verification algorithm exactly as they are in the message being 382 | // signed or verified. In particular, header field names MUST NOT be 383 | // case folded and whitespace MUST NOT be changed. 384 | return header, nil 385 | } else if algo == "relaxed" { 386 | // The "relaxed" header canonicalization algorithm MUST apply the 387 | // following steps in order: 388 | 389 | // Convert all header field names (not the header field values) to 390 | // lowercase. For example, convert "SUBJect: AbC" to "subject: AbC". 391 | 392 | // Unfold all header field continuation lines as described in 393 | // [RFC5322]; in particular, lines with terminators embedded in 394 | // continued header field values (that is, CRLF sequences followed by 395 | // WSP) MUST be interpreted without the CRLF. Implementations MUST 396 | // NOT remove the CRLF at the end of the header field value. 397 | 398 | // Convert all sequences of one or more WSP characters to a single SP 399 | // character. WSP characters here include those before and after a 400 | // line folding boundary. 401 | 402 | // Delete all WSP characters at the end of each unfolded header field 403 | // value. 404 | 405 | // Delete any WSP characters remaining before and after the colon 406 | // separating the header field name from the header field value. The 407 | // colon separator MUST be retained. 408 | kv := strings.SplitN(header, ":", 2) 409 | if len(kv) != 2 { 410 | return header, ErrBadMailFormatHeaders 411 | } 412 | k := strings.ToLower(kv[0]) 413 | k = strings.TrimSpace(k) 414 | v := removeFWS(kv[1]) 415 | // v = rxReduceWS.ReplaceAllString(v, " ") 416 | // v = strings.TrimSpace(v) 417 | return k + ":" + v + CRLF, nil 418 | } 419 | return header, ErrSignBadCanonicalization 420 | } 421 | 422 | // getBodyHash return the hash (bas64encoded) of the body 423 | func getBodyHash(body *[]byte, algo string, bodyLength uint) (string, error) { 424 | var h hash.Hash 425 | if algo == "sha1" { 426 | h = sha1.New() 427 | } else { 428 | h = sha256.New() 429 | } 430 | toH := *body 431 | // if l tag (body length) 432 | if bodyLength != 0 { 433 | if uint(len(toH)) < bodyLength { 434 | return "", ErrBadDKimTagLBodyTooShort 435 | } 436 | toH = toH[0:bodyLength] 437 | } 438 | 439 | h.Write(toH) 440 | return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil 441 | } 442 | 443 | // getSignature return signature of toSign using key 444 | func getSignature(toSign *[]byte, key *rsa.PrivateKey, algo string) (string, error) { 445 | var h1 hash.Hash 446 | var h2 crypto.Hash 447 | switch algo { 448 | case "sha1": 449 | h1 = sha1.New() 450 | h2 = crypto.SHA1 451 | break 452 | case "sha256": 453 | h1 = sha256.New() 454 | h2 = crypto.SHA256 455 | break 456 | default: 457 | return "", ErrVerifyInappropriateHashAlgo 458 | } 459 | 460 | // sign 461 | h1.Write(*toSign) 462 | sig, err := rsa.SignPKCS1v15(rand.Reader, key, h2, h1.Sum(nil)) 463 | if err != nil { 464 | return "", err 465 | } 466 | return base64.StdEncoding.EncodeToString(sig), nil 467 | } 468 | 469 | // verifySignature verify signature from pubkey 470 | func verifySignature(toSign []byte, sig64 string, key *rsa.PublicKey, algo string) error { 471 | var h1 hash.Hash 472 | var h2 crypto.Hash 473 | switch algo { 474 | case "sha1": 475 | h1 = sha1.New() 476 | h2 = crypto.SHA1 477 | break 478 | case "sha256": 479 | h1 = sha256.New() 480 | h2 = crypto.SHA256 481 | break 482 | default: 483 | return ErrVerifyInappropriateHashAlgo 484 | } 485 | 486 | h1.Write(toSign) 487 | sig, err := base64.StdEncoding.DecodeString(sig64) 488 | if err != nil { 489 | return err 490 | } 491 | return rsa.VerifyPKCS1v15(key, h2, h1.Sum(nil), sig) 492 | } 493 | 494 | // removeFWS removes all FWS from string 495 | func removeFWS(in string) string { 496 | rxReduceWS := regexp.MustCompile(`[ \t]+`) 497 | out := strings.Replace(in, "\n", "", -1) 498 | out = strings.Replace(out, "\r", "", -1) 499 | out = rxReduceWS.ReplaceAllString(out, " ") 500 | return strings.TrimSpace(out) 501 | } 502 | 503 | // validateCanonicalization validate canonicalization (c flag) 504 | func validateCanonicalization(cano string) (string, error) { 505 | p := strings.Split(cano, "/") 506 | if len(p) > 2 { 507 | return "", ErrSignBadCanonicalization 508 | } 509 | if len(p) == 1 { 510 | cano = cano + "/simple" 511 | } 512 | for _, c := range p { 513 | if c != "simple" && c != "relaxed" { 514 | return "", ErrSignBadCanonicalization 515 | } 516 | } 517 | return cano, nil 518 | } 519 | 520 | // getHeadersList returns headers as list 521 | func getHeadersList(rawHeader *[]byte) (*list.List, error) { 522 | headersList := list.New() 523 | currentHeader := []byte{} 524 | for _, line := range bytes.SplitAfter(*rawHeader, []byte{10}) { 525 | if line[0] == 32 || line[0] == 9 { 526 | if len(currentHeader) == 0 { 527 | return headersList, ErrBadMailFormatHeaders 528 | } 529 | currentHeader = append(currentHeader, line...) 530 | } else { 531 | // New header, save current if exists 532 | if len(currentHeader) != 0 { 533 | headersList.PushBack(string(bytes.TrimRight(currentHeader, "\r\n"))) 534 | currentHeader = []byte{} 535 | } 536 | currentHeader = append(currentHeader, line...) 537 | } 538 | } 539 | headersList.PushBack(string(currentHeader)) 540 | return headersList, nil 541 | } 542 | 543 | // getHeadersBody return headers and body 544 | func getHeadersBody(email *[]byte) ([]byte, []byte, error) { 545 | substitutedEmail := *email 546 | 547 | // only replace \n with \r\n when \r\n\r\n not exists 548 | if bytes.Index(*email, []byte{13, 10, 13, 10}) < 0 { 549 | // \n -> \r\n 550 | substitutedEmail = bytes.Replace(*email, []byte{10}, []byte{13, 10}, -1) 551 | } 552 | 553 | parts := bytes.SplitN(substitutedEmail, []byte{13, 10, 13, 10}, 2) 554 | if len(parts) != 2 { 555 | return []byte{}, []byte{}, ErrBadMailFormat 556 | } 557 | // Empty body 558 | if len(parts[1]) == 0 { 559 | parts[1] = []byte{13, 10} 560 | } 561 | return parts[0], parts[1], nil 562 | } 563 | -------------------------------------------------------------------------------- /dkimHeader.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/mail" 7 | "net/textproto" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type DKIMHeader struct { 14 | // Version This tag defines the version of DKIM 15 | // specification that applies to the signature record. 16 | // tag v 17 | Version string 18 | 19 | // The algorithm used to generate the signature.. 20 | // Verifiers MUST support "rsa-sha1" and "rsa-sha256"; 21 | // Signers SHOULD sign using "rsa-sha256". 22 | // tag a 23 | Algorithm string 24 | 25 | // The signature data (base64). 26 | // Whitespace is ignored in this value and MUST be 27 | // ignored when reassembling the original signature. 28 | // In particular, the signing process can safely insert 29 | // FWS in this value in arbitrary places to conform to line-length 30 | // limits. 31 | // tag b 32 | SignatureData string 33 | 34 | // The hash of the canonicalized body part of the message as 35 | // limited by the "l=" tag (base64; REQUIRED). 36 | // Whitespace is ignored in this value and MUST be ignored when reassembling the original 37 | // signature. In particular, the signing process can safely insert 38 | // FWS in this value in arbitrary places to conform to line-length 39 | // limits. 40 | // tag bh 41 | BodyHash string 42 | 43 | // Message canonicalization (plain-text; OPTIONAL, default is 44 | //"simple/simple"). This tag informs the Verifier of the type of 45 | // canonicalization used to prepare the message for signing. It 46 | // consists of two names separated by a "slash" (%d47) character, 47 | // corresponding to the header and body canonicalization algorithms, 48 | // respectively. These algorithms are described in Section 3.4. If 49 | // only one algorithm is named, that algorithm is used for the header 50 | // and "simple" is used for the body. For example, "c=relaxed" is 51 | // treated the same as "c=relaxed/simple". 52 | // tag c 53 | MessageCanonicalization string 54 | 55 | // The SDID claiming responsibility for an introduction of a message 56 | // into the mail stream (plain-text; REQUIRED). Hence, the SDID 57 | // value is used to form the query for the public key. The SDID MUST 58 | // correspond to a valid DNS name under which the DKIM key record is 59 | // published. The conventions and semantics used by a Signer to 60 | // create and use a specific SDID are outside the scope of this 61 | // specification, as is any use of those conventions and semantics. 62 | // When presented with a signature that does not meet these 63 | // requirements, Verifiers MUST consider the signature invalid. 64 | // Internationalized domain names MUST be encoded as A-labels, as 65 | // described in Section 2.3 of [RFC5890]. 66 | // tag d 67 | Domain string 68 | 69 | // Signed header fields (plain-text, but see description; REQUIRED). 70 | // A colon-separated list of header field names that identify the 71 | // header fields presented to the signing algorithm. The field MUST 72 | // contain the complete list of header fields in the order presented 73 | // to the signing algorithm. The field MAY contain names of header 74 | // fields that do not exist when signed; nonexistent header fields do 75 | // not contribute to the signature computation (that is, they are 76 | // treated as the null input, including the header field name, the 77 | // separating colon, the header field value, and any CRLF 78 | // terminator). The field MAY contain multiple instances of a header 79 | // field name, meaning multiple occurrences of the corresponding 80 | // header field are included in the header hash. The field MUST NOT 81 | // include the DKIM-Signature header field that is being created or 82 | // verified but may include others. Folding whitespace (FWS) MAY be 83 | // included on either side of the colon separator. Header field 84 | // names MUST be compared against actual header field names in a 85 | // case-insensitive manner. This list MUST NOT be empty. See 86 | // Section 5.4 for a discussion of choosing header fields to sign and 87 | // Section 5.4.2 for requirements when signing multiple instances of 88 | // a single field. 89 | // tag h 90 | Headers []string 91 | 92 | // The Agent or User Identifier (AUID) on behalf of which the SDID is 93 | // taking responsibility (dkim-quoted-printable; OPTIONAL, default is 94 | // an empty local-part followed by an "@" followed by the domain from 95 | // the "d=" tag). 96 | // The syntax is a standard email address where the local-part MAY be 97 | // omitted. The domain part of the address MUST be the same as, or a 98 | // subdomain of, the value of the "d=" tag. 99 | // Internationalized domain names MUST be encoded as A-labels, as 100 | // described in Section 2.3 of [RFC5890]. 101 | // tag i 102 | Auid string 103 | 104 | // Body length count (plain-text unsigned decimal integer; OPTIONAL, 105 | // default is entire body). This tag informs the Verifier of the 106 | // number of octets in the body of the email after canonicalization 107 | // included in the cryptographic hash, starting from 0 immediately 108 | // following the CRLF preceding the body. This value MUST NOT be 109 | // larger than the actual number of octets in the canonicalized 110 | // message body. See further discussion in Section 8.2. 111 | // tag l 112 | BodyLength uint 113 | 114 | // A colon-separated list of query methods used to retrieve the 115 | // public key (plain-text; OPTIONAL, default is "dns/txt"). Each 116 | // query method is of the form "type[/options]", where the syntax and 117 | // semantics of the options depend on the type and specified options. 118 | // If there are multiple query mechanisms listed, the choice of query 119 | // mechanism MUST NOT change the interpretation of the signature. 120 | // Implementations MUST use the recognized query mechanisms in the 121 | // order presented. Unrecognized query mechanisms MUST be ignored. 122 | // Currently, the only valid value is "dns/txt", which defines the 123 | // DNS TXT resource record (RR) lookup algorithm described elsewhere 124 | // in this document. The only option defined for the "dns" query 125 | // type is "txt", which MUST be included. Verifiers and Signers MUST 126 | // support "dns/txt". 127 | // tag q 128 | QueryMethods []string 129 | 130 | // The selector subdividing the namespace for the "d=" (domain) tag 131 | // (plain-text; REQUIRED). 132 | // Internationalized selector names MUST be encoded as A-labels, as 133 | // described in Section 2.3 of [RFC5890]. 134 | // tag s 135 | Selector string 136 | 137 | // Signature Timestamp (plain-text unsigned decimal integer; 138 | // RECOMMENDED, default is an unknown creation time). The time that 139 | // this signature was created. The format is the number of seconds 140 | // since 00:00:00 on January 1, 1970 in the UTC time zone. The value 141 | // is expressed as an unsigned integer in decimal ASCII. This value 142 | // is not constrained to fit into a 31- or 32-bit integer. 143 | // Implementations SHOULD be prepared to handle values up to at least 144 | // 10^12 (until approximately AD 200,000; this fits into 40 bits). 145 | // To avoid denial-of-service attacks, implementations MAY consider 146 | // any value longer than 12 digits to be infinite. Leap seconds are 147 | // not counted. Implementations MAY ignore signatures that have a 148 | // timestamp in the future. 149 | // tag t 150 | SignatureTimestamp time.Time 151 | 152 | // Signature Expiration (plain-text unsigned decimal integer; 153 | // RECOMMENDED, default is no expiration). The format is the same as 154 | // in the "t=" tag, represented as an absolute date, not as a time 155 | // delta from the signing timestamp. The value is expressed as an 156 | // unsigned integer in decimal ASCII, with the same constraints on 157 | // the value in the "t=" tag. Signatures MAY be considered invalid 158 | // if the verification time at the Verifier is past the expiration 159 | // date. The verification time should be the time that the message 160 | // was first received at the administrative domain of the Verifier if 161 | // that time is reliably available; otherwise, the current time 162 | // should be used. The value of the "x=" tag MUST be greater than 163 | // the value of the "t=" tag if both are present. 164 | //tag x 165 | SignatureExpiration time.Time 166 | 167 | // Copied header fields (dkim-quoted-printable, but see description; 168 | // OPTIONAL, default is null). A vertical-bar-separated list of 169 | // selected header fields present when the message was signed, 170 | // including both the field name and value. It is not required to 171 | // include all header fields present at the time of signing. This 172 | // field need not contain the same header fields listed in the "h=" 173 | // tag. The header field text itself must encode the vertical bar 174 | // ("|", %x7C) character (i.e., vertical bars in the "z=" text are 175 | // meta-characters, and any actual vertical bar characters in a 176 | // copied header field must be encoded). Note that all whitespace 177 | // must be encoded, including whitespace between the colon and the 178 | // header field value. After encoding, FWS MAY be added at arbitrary 179 | // locations in order to avoid excessively long lines; such 180 | // whitespace is NOT part of the value of the header field and MUST 181 | // be removed before decoding. 182 | // The header fields referenced by the "h=" tag refer to the fields 183 | // in the [RFC5322] header of the message, not to any copied fields 184 | // in the "z=" tag. Copied header field values are for diagnostic 185 | // use. 186 | // tag z 187 | CopiedHeaderFields []string 188 | 189 | // HeaderMailFromDomain store the raw email address of the header Mail From 190 | // used for verifying in case of multiple DKIM header (we will prioritise 191 | // header with d = mail from domain) 192 | //HeaderMailFromDomain string 193 | 194 | // RawForsign represents the raw part (without canonicalization) of the header 195 | // used for computint sig in verify process 196 | rawForSign string 197 | } 198 | 199 | // NewDkimHeaderBySigOptions return a new DkimHeader initioalized with sigOptions value 200 | func newDkimHeaderBySigOptions(options SigOptions) *DKIMHeader { 201 | h := new(DKIMHeader) 202 | h.Version = "1" 203 | h.Algorithm = options.Algo 204 | h.MessageCanonicalization = options.Canonicalization 205 | h.Domain = options.Domain 206 | h.Headers = options.Headers 207 | h.Auid = options.Auid 208 | h.BodyLength = options.BodyLength 209 | h.QueryMethods = options.QueryMethods 210 | h.Selector = options.Selector 211 | if options.AddSignatureTimestamp { 212 | h.SignatureTimestamp = time.Now() 213 | } 214 | if options.SignatureExpireIn > 0 { 215 | h.SignatureExpiration = time.Now().Add(time.Duration(options.SignatureExpireIn) * time.Second) 216 | } 217 | h.CopiedHeaderFields = options.CopiedHeaderFields 218 | return h 219 | } 220 | 221 | // GetHeader return a new DKIMHeader by parsing an email 222 | // Note: according to RFC 6376 an email can have multiple DKIM Header 223 | // in this case we return the last inserted or the last with d== mail from 224 | func GetHeader(email *[]byte) (*DKIMHeader, error) { 225 | m, err := mail.ReadMessage(bytes.NewReader(*email)) 226 | if err != nil { 227 | return nil, err 228 | } 229 | 230 | // DKIM header ? 231 | if len(m.Header[textproto.CanonicalMIMEHeaderKey("DKIM-Signature")]) == 0 { 232 | return nil, ErrDkimHeaderNotFound 233 | } 234 | 235 | // Get mail from domain 236 | mailFromDomain := "" 237 | mailfrom, err := mail.ParseAddress(m.Header.Get(textproto.CanonicalMIMEHeaderKey("From"))) 238 | if err != nil { 239 | if err.Error() != "mail: no address" { 240 | return nil, err 241 | } 242 | } else { 243 | t := strings.SplitAfter(mailfrom.Address, "@") 244 | if len(t) > 1 { 245 | mailFromDomain = strings.ToLower(t[1]) 246 | } 247 | } 248 | 249 | // get raw dkim header 250 | // we can't use m.header because header key will be converted with textproto.CanonicalMIMEHeaderKey 251 | // ie if key in header is not DKIM-Signature but Dkim-Signature or DKIM-signature ot... other 252 | // combination of case, verify will fail. 253 | rawHeaders, _, err := getHeadersBody(email) 254 | if err != nil { 255 | return nil, ErrBadMailFormat 256 | } 257 | rawHeadersList, err := getHeadersList(&rawHeaders) 258 | if err != nil { 259 | return nil, err 260 | } 261 | dkHeaders := []string{} 262 | for h := rawHeadersList.Front(); h != nil; h = h.Next() { 263 | if strings.HasPrefix(strings.ToLower(h.Value.(string)), "dkim-signature") { 264 | dkHeaders = append(dkHeaders, h.Value.(string)) 265 | } 266 | } 267 | 268 | var keep *DKIMHeader 269 | var keepErr error 270 | //for _, dk := range m.Header[textproto.CanonicalMIMEHeaderKey("DKIM-Signature")] { 271 | for _, h := range dkHeaders { 272 | parsed, err := parseDkHeader(h) 273 | // if malformed dkim header try next 274 | if err != nil { 275 | keepErr = err 276 | continue 277 | } 278 | // Keep first dkim headers 279 | if keep == nil { 280 | keep = parsed 281 | } 282 | // if d flag == domain keep this header and return 283 | if mailFromDomain == parsed.Domain { 284 | return parsed, nil 285 | } 286 | } 287 | if keep == nil { 288 | return nil, keepErr 289 | } 290 | return keep, nil 291 | } 292 | 293 | // parseDkHeader parse raw dkim header 294 | func parseDkHeader(header string) (dkh *DKIMHeader, err error) { 295 | dkh = new(DKIMHeader) 296 | 297 | keyVal := strings.SplitN(header, ":", 2) 298 | 299 | t := strings.LastIndex(header, "b=") 300 | if t == -1 { 301 | return nil, ErrDkimHeaderBTagNotFound 302 | } 303 | dkh.rawForSign = header[0 : t+2] 304 | p := strings.IndexByte(header[t:], ';') 305 | if p != -1 { 306 | dkh.rawForSign = dkh.rawForSign + header[t+p:] 307 | } 308 | 309 | // Mandatory 310 | mandatoryFlags := make(map[string]bool, 7) //(b'v', b'a', b'b', b'bh', b'd', b'h', b's') 311 | mandatoryFlags["v"] = false 312 | mandatoryFlags["a"] = false 313 | mandatoryFlags["b"] = false 314 | mandatoryFlags["bh"] = false 315 | mandatoryFlags["d"] = false 316 | mandatoryFlags["h"] = false 317 | mandatoryFlags["s"] = false 318 | 319 | // default values 320 | dkh.MessageCanonicalization = "simple/simple" 321 | dkh.QueryMethods = []string{"dns/txt"} 322 | 323 | // unfold && clean 324 | val := removeFWS(keyVal[1]) 325 | val = strings.Replace(val, " ", "", -1) 326 | 327 | fs := strings.Split(val, ";") 328 | for _, f := range fs { 329 | if f == "" { 330 | continue 331 | } 332 | flagData := strings.SplitN(f, "=", 2) 333 | 334 | // https://github.com/toorop/go-dkim/issues/2 335 | // if flag is not in the form key=value (eg doesn't have "=") 336 | if len(flagData) != 2 { 337 | return nil, ErrDkimHeaderBadFormat 338 | } 339 | flag := strings.ToLower(strings.TrimSpace(flagData[0])) 340 | data := strings.TrimSpace(flagData[1]) 341 | switch flag { 342 | case "v": 343 | if data != "1" { 344 | return nil, ErrDkimVersionNotsupported 345 | } 346 | dkh.Version = data 347 | mandatoryFlags["v"] = true 348 | case "a": 349 | dkh.Algorithm = strings.ToLower(data) 350 | if dkh.Algorithm != "rsa-sha1" && dkh.Algorithm != "rsa-sha256" { 351 | return nil, ErrSignBadAlgo 352 | } 353 | mandatoryFlags["a"] = true 354 | case "b": 355 | //dkh.SignatureData = removeFWS(data) 356 | // remove all space 357 | dkh.SignatureData = strings.Replace(removeFWS(data), " ", "", -1) 358 | if len(dkh.SignatureData) != 0 { 359 | mandatoryFlags["b"] = true 360 | } 361 | case "bh": 362 | dkh.BodyHash = removeFWS(data) 363 | if len(dkh.BodyHash) != 0 { 364 | mandatoryFlags["bh"] = true 365 | } 366 | case "d": 367 | dkh.Domain = strings.ToLower(data) 368 | if len(dkh.Domain) != 0 { 369 | mandatoryFlags["d"] = true 370 | } 371 | case "h": 372 | data = strings.ToLower(data) 373 | dkh.Headers = strings.Split(data, ":") 374 | if len(dkh.Headers) != 0 { 375 | mandatoryFlags["h"] = true 376 | } 377 | fromFound := false 378 | for _, h := range dkh.Headers { 379 | if h == "from" { 380 | fromFound = true 381 | } 382 | } 383 | if !fromFound { 384 | return nil, ErrDkimHeaderNoFromInHTag 385 | } 386 | case "s": 387 | dkh.Selector = strings.ToLower(data) 388 | if len(dkh.Selector) != 0 { 389 | mandatoryFlags["s"] = true 390 | } 391 | case "c": 392 | dkh.MessageCanonicalization, err = validateCanonicalization(strings.ToLower(data)) 393 | if err != nil { 394 | return nil, err 395 | } 396 | case "i": 397 | if data != "" { 398 | if !strings.HasSuffix(data, dkh.Domain) { 399 | return nil, ErrDkimHeaderDomainMismatch 400 | } 401 | dkh.Auid = data 402 | } 403 | case "l": 404 | ui, err := strconv.ParseUint(data, 10, 32) 405 | if err != nil { 406 | return nil, err 407 | } 408 | dkh.BodyLength = uint(ui) 409 | case "q": 410 | dkh.QueryMethods = strings.Split(data, ":") 411 | if len(dkh.QueryMethods) == 0 || strings.ToLower(dkh.QueryMethods[0]) != "dns/txt" { 412 | return nil, errQueryMethodNotsupported 413 | } 414 | case "t": 415 | ts, err := strconv.ParseInt(data, 10, 64) 416 | if err != nil { 417 | return nil, err 418 | } 419 | dkh.SignatureTimestamp = time.Unix(ts, 0) 420 | 421 | case "x": 422 | ts, err := strconv.ParseInt(data, 10, 64) 423 | if err != nil { 424 | return nil, err 425 | } 426 | dkh.SignatureExpiration = time.Unix(ts, 0) 427 | case "z": 428 | dkh.CopiedHeaderFields = strings.Split(data, "|") 429 | } 430 | } 431 | 432 | // All mandatory flags are in ? 433 | for _, p := range mandatoryFlags { 434 | if !p { 435 | return nil, ErrDkimHeaderMissingRequiredTag 436 | } 437 | } 438 | 439 | // default for i/Auid 440 | if dkh.Auid == "" { 441 | dkh.Auid = "@" + dkh.Domain 442 | } 443 | 444 | // defaut for query method 445 | if len(dkh.QueryMethods) == 0 { 446 | dkh.QueryMethods = []string{"dns/text"} 447 | } 448 | 449 | return dkh, nil 450 | 451 | } 452 | 453 | // GetHeaderBase return base header for signers 454 | // Todo: some refactoring needed... 455 | func (d *DKIMHeader) getHeaderBaseForSigning(bodyHash string) string { 456 | h := "DKIM-Signature: v=" + d.Version + "; a=" + d.Algorithm + "; q=" + strings.Join(d.QueryMethods, ":") + "; c=" + d.MessageCanonicalization + ";" + CRLF + TAB 457 | subh := "s=" + d.Selector + ";" 458 | if len(subh)+len(d.Domain)+4 > MaxHeaderLineLength { 459 | h += subh + FWS 460 | subh = "" 461 | } 462 | subh += " d=" + d.Domain + ";" 463 | 464 | // Auid 465 | if len(d.Auid) != 0 { 466 | if len(subh)+len(d.Auid)+4 > MaxHeaderLineLength { 467 | h += subh + FWS 468 | subh = "" 469 | } 470 | subh += " i=" + d.Auid + ";" 471 | } 472 | 473 | /*h := "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=tmail.io; i=@tmail.io;" + FWS 474 | subh := "q=dns/txt; s=test;"*/ 475 | 476 | // signature timestamp 477 | if !d.SignatureTimestamp.IsZero() { 478 | ts := d.SignatureTimestamp.Unix() 479 | if len(subh)+14 > MaxHeaderLineLength { 480 | h += subh + FWS 481 | subh = "" 482 | } 483 | subh += " t=" + fmt.Sprintf("%d", ts) + ";" 484 | } 485 | if len(subh)+len(d.Domain)+4 > MaxHeaderLineLength { 486 | h += subh + FWS 487 | subh = "" 488 | } 489 | 490 | // Expiration 491 | if !d.SignatureExpiration.IsZero() { 492 | ts := d.SignatureExpiration.Unix() 493 | if len(subh)+14 > MaxHeaderLineLength { 494 | h += subh + FWS 495 | subh = "" 496 | } 497 | subh += " x=" + fmt.Sprintf("%d", ts) + ";" 498 | } 499 | 500 | // body length 501 | if d.BodyLength != 0 { 502 | bodyLengthStr := fmt.Sprintf("%d", d.BodyLength) 503 | if len(subh)+len(bodyLengthStr)+4 > MaxHeaderLineLength { 504 | h += subh + FWS 505 | subh = "" 506 | } 507 | subh += " l=" + bodyLengthStr + ";" 508 | } 509 | 510 | // Headers 511 | if len(subh)+len(d.Headers)+4 > MaxHeaderLineLength { 512 | h += subh + FWS 513 | subh = "" 514 | } 515 | subh += " h=" 516 | for _, header := range d.Headers { 517 | if len(subh)+len(header)+1 > MaxHeaderLineLength { 518 | h += subh + FWS 519 | subh = "" 520 | } 521 | subh += header + ":" 522 | } 523 | subh = subh[:len(subh)-1] + ";" 524 | 525 | // BodyHash 526 | if len(subh)+5+len(bodyHash) > MaxHeaderLineLength { 527 | h += subh + FWS 528 | subh = "" 529 | } else { 530 | subh += " " 531 | } 532 | subh += "bh=" 533 | l := len(subh) 534 | for _, c := range bodyHash { 535 | subh += string(c) 536 | l++ 537 | if l >= MaxHeaderLineLength { 538 | h += subh + FWS 539 | subh = "" 540 | l = 0 541 | } 542 | } 543 | h += subh + ";" + FWS + "b=" 544 | return h 545 | } 546 | -------------------------------------------------------------------------------- /dkim_test.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | //"fmt" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "net" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | const ( 17 | privKey = `-----BEGIN RSA PRIVATE KEY----- 18 | MIICXQIBAAKBgQDNUXO+Qsl1tw+GjrqFajz0ERSEUs1FHSL/+udZRWn1Atw8gz0+ 19 | tcGqhWChBDeU9gY5sKLEAZnX3FjC/T/IbqeiSM68kS5vLkzRI84eiJrm3+IieUqI 20 | IicsO+WYxQs+JgVx5XhpPjX4SQjHtwEC2xKkWnEv+VPgO1JWdooURcSC6QIDAQAB 21 | AoGAM9exRgVPIS4L+Ynohu+AXJBDgfX2ZtEomUIdUGk6i+cg/RaWTFNQh2IOOBn8 22 | ftxwTfjP4HYXBm5Y60NO66klIlzm6ci303IePmjaj8tXQiriaVA0j4hmW+xgnqQX 23 | PubFzfnR2eWLSOGChrNFbd3YABC+qttqT6vT0KpFyLdn49ECQQD3zYCpgelb0EBo 24 | gc5BVGkbArcknhPwO39coPqKM4csu6cgI489XpF7iMh77nBTIiy6dsDdRYXZM3bq 25 | ELTv6K4/AkEA1BwsIZG51W5DRWaKeobykQIB6FqHLW+Zhedw7BnxS8OflYAcSWi4 26 | uGhq0DPojmhsmUC8jUeLe79CllZNP3LU1wJBAIZcoCnI7g5Bcdr4nyxfJ4pkw4cQ 27 | S4FT0XAZPR/YZrADo8/SWCWPdFTGSuaf17nL6vLD1zljK/skY5LwshrvUCMCQQDM 28 | MY7ehj6DVFHYlt2LFSyhInCZscTencgK24KfGF5t1JZlwt34YaMqjAMACmi/55Fc 29 | e7DIxW5nI/nDZrOY+EAjAkA3BHUx3PeXkXJnXjlh7nGZmk/v8tB5fiofAwfXNfL7 30 | bz0ZrT2Caz995Dpjommh5aMpCJvUGsrYCG6/Pbha9NXl 31 | -----END RSA PRIVATE KEY-----` 32 | 33 | pubKey = `MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNUXO+Qsl1tw+GjrqFajz0ERSE 34 | Us1FHSL/+udZRWn1Atw8gz0+tcGqhWChBDeU9gY5sKLEAZnX3FjC/T/IbqeiSM68 35 | kS5vLkzRI84eiJrm3+IieUqIIicsO+WYxQs+JgVx5XhpPjX4SQjHtwEC2xKkWnEv 36 | +VPgO1JWdooURcSC6QIDAQAB` 37 | 38 | domain = "tmail.io" 39 | 40 | selector = "test" 41 | ) 42 | 43 | func privKeyRSA(tb testing.TB) *rsa.PrivateKey { 44 | block, rest := pem.Decode([]byte(privKey)) 45 | require.NotNil(tb, block) 46 | require.Empty(tb, rest) 47 | 48 | key, err := x509.ParsePKCS1PrivateKey(block.Bytes) 49 | require.NoError(tb, err) 50 | 51 | return key 52 | } 53 | 54 | var emailBase = "Received: (qmail 28277 invoked from network); 1 May 2015 09:43:37 -0000" + CRLF + 55 | "Received: (qmail 21323 invoked from network); 1 May 2015 09:48:39 -0000" + CRLF + 56 | "Received: from mail483.ha.ovh.net (b6.ovh.net [213.186.33.56])" + CRLF + 57 | " by mo51.mail-out.ovh.net (Postfix) with SMTP id A6E22FF8934" + CRLF + 58 | " for ; Mon, 4 May 2015 14:00:47 +0200 (CEST)" + CRLF + 59 | "MIME-Version: 1.0" + CRLF + 60 | "Date: Fri, 1 May 2015 11:48:37 +0200" + CRLF + 61 | "Message-ID: " + CRLF + 62 | "Subject: Test DKIM" + CRLF + 63 | "From: =?UTF-8?Q?St=C3=A9phane_Depierrepont?= " + CRLF + 64 | "To: =?UTF-8?Q?St=C3=A9phane_Depierrepont?= " + CRLF + 65 | "Content-Type: text/plain; charset=UTF-8" + CRLF + CRLF + 66 | "Hello world" + CRLF + 67 | "line with trailing space " + CRLF + 68 | "line with space " + CRLF + 69 | "-- " + CRLF + 70 | "Toorop" + CRLF + CRLF + CRLF + CRLF + CRLF + CRLF 71 | 72 | var emailBaseNoFrom = "Received: (qmail 28277 invoked from network); 1 May 2015 09:43:37 -0000" + CRLF + 73 | "Received: (qmail 21323 invoked from network); 1 May 2015 09:48:39 -0000" + CRLF + 74 | "Received: from mail483.ha.ovh.net (b6.ovh.net [213.186.33.56])" + CRLF + 75 | " by mo51.mail-out.ovh.net (Postfix) with SMTP id A6E22FF8934" + CRLF + 76 | " for ; Mon, 4 May 2015 14:00:47 +0200 (CEST)" + CRLF + 77 | "MIME-Version: 1.0" + CRLF + 78 | "Date: Fri, 1 May 2015 11:48:37 +0200" + CRLF + 79 | "Message-ID: " + CRLF + 80 | "Subject: Test DKIM" + CRLF + 81 | "To: =?UTF-8?Q?St=C3=A9phane_Depierrepont?= " + CRLF + 82 | "Content-Type: text/plain; charset=UTF-8" + CRLF + CRLF + 83 | "Hello world" + CRLF + 84 | "line with trailing space " + CRLF + 85 | "line with space " + CRLF + 86 | "-- " + CRLF + 87 | "Toorop" + CRLF + CRLF + CRLF + CRLF + CRLF + CRLF 88 | 89 | var headerSimple = "From: =?UTF-8?Q?St=C3=A9phane_Depierrepont?= " + CRLF + 90 | "Date: Fri, 1 May 2015 11:48:37 +0200" + CRLF + 91 | "MIME-Version: 1.0" + CRLF + 92 | "Received: from mail483.ha.ovh.net (b6.ovh.net [213.186.33.56])" + CRLF + 93 | " by mo51.mail-out.ovh.net (Postfix) with SMTP id A6E22FF8934" + CRLF + 94 | " for ; Mon, 4 May 2015 14:00:47 +0200 (CEST)" + CRLF + 95 | "Received: (qmail 21323 invoked from network); 1 May 2015 09:48:39 -0000" + CRLF 96 | 97 | var headerRelaxed = "from:=?UTF-8?Q?St=C3=A9phane_Depierrepont?= " + CRLF + 98 | "date:Fri, 1 May 2015 11:48:37 +0200" + CRLF + 99 | "mime-version:1.0" + CRLF + 100 | "received:from mail483.ha.ovh.net (b6.ovh.net [213.186.33.56]) by mo51.mail-out.ovh.net (Postfix) with SMTP id A6E22FF8934 for ; Mon, 4 May 2015 14:00:47 +0200 (CEST)" + CRLF + 101 | "received:(qmail 21323 invoked from network); 1 May 2015 09:48:39 -0000" + CRLF 102 | 103 | var bodySimple = "Hello world" + CRLF + 104 | "line with trailing space " + CRLF + 105 | "line with space " + CRLF + 106 | "-- " + CRLF + 107 | "Toorop" + CRLF 108 | 109 | var bodyRelaxed = "Hello world" + CRLF + 110 | "line with trailing space" + CRLF + 111 | "line with space" + CRLF + 112 | "--" + CRLF + 113 | "Toorop" + CRLF 114 | 115 | var signedRelaxedRelaxed = "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed;" + CRLF + 116 | " s=test; d=tmail.io; h=from:date:mime-version:received:received;" + CRLF + 117 | " bh=4pCY+Pp2c/Wr8fDfBDWKpx3DDsr0CJfSP9H1KYxm5bA=;" + CRLF + 118 | " b=o0eE20jd8jYqkyxP5rqbfcoUABWZyfrL+l3e1lC0Z+b1Azyrdv+UMmx8L5F57Rhya1SNG2" + CRLF + 119 | " 9FnMUTwq+u1PmOmB7NwfTq5UCS9UR8wrNffI1mLUsBPFtv+jZtnHzdmR9aCo2HPfBBALC8" + CRLF + 120 | " jEhQcvm/RaP0aiYJtisLJ86S3k0P1WU=" + CRLF + emailBase 121 | 122 | var signedRelaxedRelaxedLength = "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed;" + CRLF + 123 | " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + 124 | " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + 125 | " b=byhiFWd0lAM1sqD1tl8S1DZtKNqgiEZp8jrGds6RRydnZkdX9rCPeL0Q5MYWBQ/JmQrml5" + CRLF + 126 | " pIghLwl/EshDBmNy65O6qO8pSSGgZmM3T7SRLMloex8bnrBJ4KSYcHV46639gVEWcBOKW0" + CRLF + 127 | " h1djZu2jaTuxGeJzlFVtw3Arf2B93cc=" + CRLF + emailBase 128 | 129 | var signedSimpleSimple = "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=simple/simple;" + CRLF + 130 | " s=test; d=tmail.io; h=from:date:mime-version:received:received;" + CRLF + 131 | " bh=ZrMyJ01ZlWHPSzskR7A+4CeBDAd0m8CPny4m15ablao=;" + CRLF + 132 | " b=nzkqVMlEBL+6m/1AtlFzGV2tHjvfNwFmz9kUDNqphBNSvguv/8KAdqsVheBudJBDHNPrjr" + CRLF + 133 | " +N57+atXBQX/jng2WAlI5wpQb1TlxLfm8b7SyS1Z7WwSOI0MqaLMhIss4QEVsevaTF1d/1" + CRLF + 134 | " WcFzOPxn66nnn+CRKaz553tjIn1GeFQ=" + CRLF + emailBase 135 | 136 | var signedSimpleSimpleLength = "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=simple/simple;" + CRLF + 137 | " s=test; d=tmail.io; l=5; h=from:subject:date:message-id;" + CRLF + 138 | " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + 139 | " b=P4cX4WxnSytfsQ3skg3fYIRljleh2iDJidlr/GPfA4S8pTPNZj4SPhB7CJ6OcbSWwJ6Yer" + CRLF + 140 | " rHGEmCSEGHJPQm+P12iujJlQ784i34JsBvMC5YAMIQ0DHTNhJRHEyShg1I0B3tqArogdap" + CRLF + 141 | " qwWLUSFEhPTXglZVhcHIvYZA9X38iF4=" + CRLF + emailBase 142 | 143 | var signedNoFrom = "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=simple/simple;" + CRLF + 144 | " s=test; d=tmail.io; h=from:date:mime-version:received:received;" + CRLF + 145 | " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + 146 | " b=SoEhlu1Emm2ASqo8jMhz6FIf2nNHt3ouY4Av/pFFEkQ048RqUFP437ap7RbtL2wh0N3Kkm" + CRLF + 147 | " AKF2TcTLZ++1nalq+djU+/aP4KYQd4RWWFBjkxDzvCH4bvB1M5AGp4Qz9ldmdMQBWOvvSp" + CRLF + 148 | " DIpJW4XNA/uqLSswtjCYbJsSg9Ywv1o=" + CRLF + emailBaseNoFrom 149 | 150 | var signedMissingFlag = "DKIM-Signature: v=1; q=dns/txt; c=simple/simple;" + CRLF + 151 | " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + 152 | " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + 153 | " b=SoEhlu1Emm2ASqo8jMhz6FIf2nNHt3ouY4Av/pFFEkQ048RqUFP437ap7RbtL2wh0N3Kkm" + CRLF + 154 | " AKF2TcTLZ++1nalq+djU+/aP4KYQd4RWWFBjkxDzvCH4bvB1M5AGp4Qz9ldmdMQBWOvvSp" + CRLF + 155 | " DIpJW4XNA/uqLSswtjCYbJsSg9Ywv1o=" + CRLF + emailBase 156 | 157 | var signedBadAFlag = "DKIM-Signature: v=1; a=rsashasha sfds; q=dns/txt; c=simple/simple;" + CRLF + 158 | " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + 159 | " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + 160 | " b=SoEhlu1Emm2ASqo8jMhz6FIf2nNHt3ouY4Av/pFFEkQ048RqUFP437ap7RbtL2wh0N3Kkm" + CRLF + 161 | " AKF2TcTLZ++1nalq+djU+/aP4KYQd4RWWFBjkxDzvCH4bvB1M5AGp4Qz9ldmdMQBWOvvSp" + CRLF + 162 | " DIpJW4XNA/uqLSswtjCYbJsSg9Ywv1o=" + CRLF + emailBase 163 | 164 | var signedBadAlgo = "DKIM-Signature: v=1; a=rsa-shasha; q=dns/txt; c=simple/simple;" + CRLF + 165 | " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + 166 | " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + 167 | " b=SoEhlu1Emm2ASqo8jMhz6FIf2nNHt3ouY4Av/pFFEkQ048RqUFP437ap7RbtL2wh0N3Kkm" + CRLF + 168 | " AKF2TcTLZ++1nalq+djU+/aP4KYQd4RWWFBjkxDzvCH4bvB1M5AGp4Qz9ldmdMQBWOvvSp" + CRLF + 169 | " DIpJW4XNA/uqLSswtjCYbJsSg9Ywv1o=" + CRLF + emailBase 170 | 171 | var signedDouble = "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=simple/simple;" + CRLF + 172 | " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + 173 | " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + 174 | " b=SoEhlu1Emm2ASqo8jMhz6FIf2nNHt3ouY4Av/pFFEkQ048RqUFP437ap7RbtL2wh0N3Kkm" + CRLF + 175 | " AKF2TcTLZ++1nalq+djU+/aP4KYQd4RWWFBjkxDzvCH4bvB1M5AGp4Qz9ldmdMQBWOvvSp" + CRLF + 176 | " DIpJW4XNA/uqLSswtjCYbJsSg9Ywv1o=" + CRLF + 177 | "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed;" + CRLF + 178 | " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + 179 | " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + 180 | " b=byhiFWd0lAM1sqD1tl8S1DZtKNqgiEZp8jrGds6RRydnZkdX9rCPeL0Q5MYWBQ/JmQrml5" + CRLF + 181 | " pIghLwl/EshDBmNy65O6qO8pSSGgZmM3T7SRLMloex8bnrBJ4KSYcHV46639gVEWcBOKW0" + CRLF + 182 | " h1djZu2jaTuxGeJzlFVtw3Arf2B93cc=" + CRLF + emailBase 183 | 184 | var fromGmail = "Return-Path: toorop@gmail.com" + CRLF + 185 | "Delivered-To: toorop@tmail.io" + CRLF + 186 | "Received: tmail deliverd local d9ae3ac7c238a50a6e007d207337752eb04038ff; 21 May 2015 19:47:54 +0200" + CRLF + 187 | "X-Env-From: toorop@gmail.com" + CRLF + 188 | "Received: from 209.85.217.176 (mail-lb0-f176.google.com.) (mail-lb0-f176.google.com)" + CRLF + 189 | " by 5.196.15.145 (mail.tmail.io.) with ESMTPS; 21 May 2015 19:47:54 +0200; tmail 0.0.8" + CRLF + 190 | " ; 8008e7eae6f168de88db072ead2b34d0f9194cc5" + CRLF + 191 | "Authentication-Results: dkim=permfail body hash did not verify" + CRLF + 192 | "Received: by lbbqq2 with SMTP id qq2so23551469lbb.3" + CRLF + 193 | " for ; Thu, 21 May 2015 10:43:42 -0700 (PDT)" + CRLF + 194 | "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;" + CRLF + 195 | " d=gmail.com; s=20120113;" + CRLF + 196 | " h=mime-version:date:message-id:subject:from:to:content-type;" + CRLF + 197 | " bh=pwO8HiXlNND4gOHL7bTlAtJFqYruIH1x8q3dAqEw138=;" + CRLF + 198 | " b=lh5rCv0Y2uh23DLUv+YsPZEmJMkhxlVRG+aeCmtJ5BpXTbSHldmNv1vbSegCx0LY9K" + CRLF + 199 | " l0AEGrpce6YgBk5qRphffEOhANKEkrLesMUyI3yc9JG2J6R19mJ/NyDkT5USZZuI8DOp" + CRLF + 200 | " GkRQSIPU4lrj3U27pr6+8I2lANJfINkqbkbBb69068/aPYl2DUMP5SPCFNwB01LHWKqI" + CRLF + 201 | " srRDhqRYnAql+PZJVbzrue2HwBflr4ycDzhfZ+Q5BxQZt+TJtzkCUHTGtx5z9JctR93E" + CRLF + 202 | " K5hUpKBN6w6GEbj1HDiMsYZOICx3XNDkny8HhFmU0nPjwbHN2C8HslOGZtDPeZWJypSG" + CRLF + 203 | " Wuig==" + CRLF + 204 | "MIME-Version: 1.0" + CRLF + 205 | "X-Received: by 10.152.206.103 with SMTP id ln7mr3235525lac.40.1432230222503;" + CRLF + 206 | " Thu, 21 May 2015 10:43:42 -0700 (PDT)" + CRLF + 207 | "Received: by 10.112.162.129 with HTTP; Thu, 21 May 2015 10:43:42 -0700 (PDT)" + CRLF + 208 | "Date: Thu, 21 May 2015 19:43:42 +0200" + CRLF + 209 | "Message-ID: " + CRLF + 210 | "Subject: Test smtpdData" + CRLF + 211 | "From: =?UTF-8?Q?St=C3=A9phane_Depierrepont?= " + CRLF + 212 | "To: toorop@tmail.io" + CRLF + 213 | "Content-Type: text/plain; charset=UTF-8" + CRLF + CRLF + 214 | "Alors ?" + CRLF + CRLF + 215 | "-- " + CRLF + 216 | "Toorop" + CRLF + 217 | "http://www.protecmail.com" + CRLF + CRLF + CRLF 218 | 219 | var missingHeaderMail = "Received: tmail deliverd remote 439903a23facd153908f3e17fb487962d01f4b44; 02 Jun 2015 10:00:24 +0000" + CRLF + 220 | "X-Env-From: toorop@toorop.fr" + CRLF + 221 | "Received: from 192.168.0.2 (no reverse) by 192.168.0.46 (no reverse) whith" + CRLF + 222 | " SMTP; 02 Jun 2015 10:00:23 +0000; tmail 0.0.8;" + CRLF + 223 | " d3c348615ef29692ca8bdacb40d0e147c977579c" + CRLF + 224 | "Message-ID: <1433239223.d3c348615ef29692ca8bdacb40d0e147c977579c@toorop.fr>" + CRLF + 225 | "Date: Thu, 21 May 2015 19:43:42 +0200" + CRLF + 226 | "Subject: test" + CRLF + CRLF + 227 | "test" 228 | 229 | func Test_NewSigOptions(t *testing.T) { 230 | options := NewSigOptions() 231 | assert.Equal(t, "rsa-sha256", options.Algo) 232 | assert.Equal(t, "simple/simple", options.Canonicalization) 233 | } 234 | 235 | func Test_SignConfig(t *testing.T) { 236 | email := []byte(emailBase) 237 | emailToTest := append([]byte(nil), email...) 238 | options := NewSigOptions() 239 | err := Sign(&emailToTest, options) 240 | assert.NotNil(t, err) 241 | // && err No private key 242 | assert.EqualError(t, err, ErrSignPrivateKeyRequired.Error()) 243 | options.PrivateKey = []byte(privKey) 244 | emailToTest = append([]byte(nil), email...) 245 | err = Sign(&emailToTest, options) 246 | 247 | // Domain 248 | assert.EqualError(t, err, ErrSignDomainRequired.Error()) 249 | options.Domain = "toorop.fr" 250 | emailToTest = append([]byte(nil), email...) 251 | err = Sign(&emailToTest, options) 252 | 253 | // Selector 254 | assert.Error(t, err, ErrSignSelectorRequired.Error()) 255 | options.Selector = "default" 256 | emailToTest = append([]byte(nil), email...) 257 | err = Sign(&emailToTest, options) 258 | assert.NoError(t, err) 259 | 260 | // Canonicalization 261 | options.Canonicalization = "simple/relaxed/simple" 262 | emailToTest = append([]byte(nil), email...) 263 | err = Sign(&emailToTest, options) 264 | assert.EqualError(t, err, ErrSignBadCanonicalization.Error()) 265 | 266 | options.Canonicalization = "simple/relax" 267 | emailToTest = append([]byte(nil), email...) 268 | err = Sign(&emailToTest, options) 269 | assert.EqualError(t, err, ErrSignBadCanonicalization.Error()) 270 | 271 | options.Canonicalization = "relaxed" 272 | emailToTest = append([]byte(nil), email...) 273 | err = Sign(&emailToTest, options) 274 | assert.NoError(t, err) 275 | 276 | options.Canonicalization = "SiMple/relAxed" 277 | emailToTest = append([]byte(nil), email...) 278 | err = Sign(&emailToTest, options) 279 | assert.NoError(t, err) 280 | 281 | // header 282 | options.Headers = []string{"toto"} 283 | emailToTest = append([]byte(nil), email...) 284 | err = Sign(&emailToTest, options) 285 | assert.EqualError(t, err, ErrSignHeaderShouldContainsFrom.Error()) 286 | 287 | options.Headers = []string{"To", "From"} 288 | emailToTest = append([]byte(nil), email...) 289 | err = Sign(&emailToTest, options) 290 | assert.NoError(t, err) 291 | } 292 | 293 | func Test_canonicalize(t *testing.T) { 294 | email := []byte(emailBase) 295 | emailToTest := append([]byte(nil), email...) 296 | options := NewSigOptions() 297 | options.Headers = []string{"from", "date", "mime-version", "received", "received", "In-Reply-To"} 298 | // simple/simple 299 | options.Canonicalization = "simple/simple" 300 | header, body, err := canonicalize(&emailToTest, options.Canonicalization, options.Headers) 301 | assert.NoError(t, err) 302 | assert.Equal(t, []byte(headerSimple), header) 303 | assert.Equal(t, []byte(bodySimple), body) 304 | 305 | // relaxed/relaxed 306 | emailToTest = append([]byte(nil), email...) 307 | options.Canonicalization = "relaxed/relaxed" 308 | header, body, err = canonicalize(&emailToTest, options.Canonicalization, options.Headers) 309 | assert.NoError(t, err) 310 | assert.Equal(t, []byte(headerRelaxed), header) 311 | assert.Equal(t, []byte(bodyRelaxed), body) 312 | } 313 | 314 | func Test_Sign(t *testing.T) { 315 | email := []byte(emailBase) 316 | emailRelaxed := append([]byte(nil), email...) 317 | options := NewSigOptions() 318 | options.PrivateKey = []byte(privKey) 319 | options.Domain = domain 320 | options.Selector = selector 321 | // options.SignatureExpireIn = 3600 322 | options.Headers = []string{"from", "date", "mime-version", "received", "received"} 323 | options.AddSignatureTimestamp = false 324 | 325 | options.Canonicalization = "relaxed/relaxed" 326 | err := Sign(&emailRelaxed, options) 327 | assert.NoError(t, err) 328 | assert.Equal(t, []byte(signedRelaxedRelaxed), emailRelaxed) 329 | 330 | options.BodyLength = 5 331 | emailRelaxed = append([]byte(nil), email...) 332 | err = Sign(&emailRelaxed, options) 333 | assert.NoError(t, err) 334 | assert.Equal(t, []byte(signedRelaxedRelaxedLength), emailRelaxed) 335 | 336 | options.BodyLength = 0 337 | options.Canonicalization = "simple/simple" 338 | emailSimple := append([]byte(nil), email...) 339 | err = Sign(&emailSimple, options) 340 | assert.Equal(t, []byte(signedSimpleSimple), emailSimple) 341 | 342 | options.Headers = []string{"from", "subject", "date", "message-id"} 343 | memail := []byte(missingHeaderMail) 344 | err = Sign(&memail, options) 345 | assert.NoError(t, err) 346 | 347 | options.BodyLength = 5 348 | options.Canonicalization = "simple/simple" 349 | emailSimple = append([]byte(nil), email...) 350 | err = Sign(&emailSimple, options) 351 | assert.Equal(t, []byte(signedSimpleSimpleLength), emailSimple) 352 | } 353 | 354 | func Test_Verify(t *testing.T) { 355 | resolveTXT := DNSOptLookupTXT(func(name string) ([]string, error) { 356 | switch name { 357 | case selector + "._domainkey." + domain: 358 | return []string{"v=DKIM1; t=y; p=" + pubKey}, nil 359 | // case "TODO._domainkey.gmail.com": 360 | // return []string{"v=DKIM1; p="}, nil 361 | default: 362 | return net.LookupTXT(name) 363 | } 364 | }) 365 | 366 | // no DKIM header 367 | email := []byte(emailBase) 368 | status, err := Verify(&email, resolveTXT) 369 | assert.Equal(t, NOTSIGNED, status) 370 | assert.Equal(t, ErrDkimHeaderNotFound, err) 371 | 372 | // No From 373 | email = []byte(signedNoFrom) 374 | status, err = Verify(&email, resolveTXT) 375 | assert.Equal(t, ErrVerifyBodyHash, err) 376 | assert.Equal(t, TESTINGPERMFAIL, status) // cause we use dkheader of the "with from" email 377 | 378 | // missing mandatory 'a' flag 379 | email = []byte(signedMissingFlag) 380 | status, err = Verify(&email, resolveTXT) 381 | assert.Error(t, err) 382 | assert.Equal(t, PERMFAIL, status) 383 | assert.Equal(t, ErrDkimHeaderMissingRequiredTag, err) 384 | 385 | // missing bad algo 386 | email = []byte(signedBadAlgo) 387 | status, err = Verify(&email, resolveTXT) 388 | assert.Equal(t, PERMFAIL, status) 389 | assert.Equal(t, ErrSignBadAlgo, err) 390 | 391 | // bad a flag 392 | email = []byte(signedBadAFlag) 393 | status, err = Verify(&email, resolveTXT) 394 | assert.Equal(t, PERMFAIL, status) 395 | assert.Equal(t, ErrSignBadAlgo, err) 396 | 397 | // relaxed 398 | email = []byte(signedRelaxedRelaxedLength) 399 | status, err = Verify(&email, resolveTXT) 400 | assert.NoError(t, err) 401 | assert.Equal(t, SUCCESS, status) 402 | 403 | // simple 404 | email = []byte(signedSimpleSimpleLength) 405 | status, err = Verify(&email, resolveTXT) 406 | assert.NoError(t, err) 407 | assert.Equal(t, SUCCESS, status) 408 | 409 | // gmail 410 | // TODO: 411 | // Google removed this DNS record some time ago. Someone will have to send an email they're 412 | // OK with being publicly available, replace the value of the fromGmail var with that, then grab 413 | // the DNS record indicated in the DKIM signature and update the resolveTXT function to return 414 | // it when asked. Then this should work. 415 | // email = []byte(fromGmail) 416 | // status, err = Verify(&email, resolveTXT) 417 | // assert.NoError(t, err) 418 | // assert.Equal(t, SUCCESS, status) 419 | } 420 | 421 | func Test_SignatureExpiration(t *testing.T) { 422 | email := []byte(emailBase) 423 | options := NewSigOptions() 424 | options.PrivateKey = []byte(privKey) 425 | options.Domain = domain 426 | options.Selector = selector 427 | options.Headers = []string{"from", "date", "mime-version", "received", "received"} 428 | options.AddSignatureTimestamp = true 429 | options.SignatureExpireIn = 1 // 1 second for testing 430 | 431 | // Sign the email 432 | err := Sign(&email, options) 433 | assert.NoError(t, err) 434 | 435 | // Wait for the signature to expire 436 | time.Sleep(2 * time.Second) 437 | 438 | // Verify the email 439 | resolveTXT := DNSOptLookupTXT(func(name string) ([]string, error) { 440 | switch name { 441 | case selector + "._domainkey." + domain: 442 | return []string{"v=DKIM1; t=y; p=" + pubKey}, nil 443 | default: 444 | return net.LookupTXT(name) 445 | } 446 | }) 447 | 448 | status, err := Verify(&email, resolveTXT) 449 | assert.Equal(t, TESTINGPERMFAIL, status) 450 | assert.Equal(t, ErrVerifySignatureHasExpired, err) 451 | } 452 | --------------------------------------------------------------------------------