diff --git a/dkim.go b/dkim.go index da7518c..95f43b1 100644 --- a/dkim.go +++ b/dkim.go @@ -68,14 +68,14 @@ type sigOptions struct { SignatureExpireIn uint64 // CopiedHeaderFileds - CopiedHeaderFileds []string + CopiedHeaderFields []string } // NewSigOption returns new sigoption with some defaults value func NewSigOptions() sigOptions { return sigOptions{ Version: 1, - Canonicalization: "relaxed/simple", + Canonicalization: "simple/simple", Algo: "rsa-sha256", Headers: []string{"from"}, BodyLength: 0, @@ -111,18 +111,9 @@ func Sign(email *[]byte, options sigOptions) error { } // Canonicalization - options.Canonicalization = strings.ToLower(options.Canonicalization) - p := strings.Split(options.Canonicalization, "/") - if len(p) > 2 { - return ErrSignBadCanonicalization - } - if len(p) == 1 { - options.Canonicalization = options.Canonicalization + "/simple" - } - for _, c := range p { - if c != "simple" && c != "relaxed" { - return ErrSignBadCanonicalization - } + options.Canonicalization, err = validateCanonicalization(strings.ToLower(options.Canonicalization)) + if err != nil { + return err } // Algo @@ -146,7 +137,7 @@ func Sign(email *[]byte, options sigOptions) error { } // Normalize - headers, body, err := canonicalize(email, options) + headers, body, err := canonicalize(email, options.Canonicalization, options.Headers) if err != nil { return err } @@ -179,7 +170,7 @@ func Sign(email *[]byte, options sigOptions) error { // Get dkim header base dkimHeader := NewDkimHeaderBySigOptions(options) - dHeader := dkimHeader.GetHeaderBase(bodyHash) + dHeader := dkimHeader.GetHeaderBaseForSigning(bodyHash) canonicalizations := strings.Split(options.Canonicalization, "/") dHeaderCanonicalized, err := canonicalizeHeader(dHeader, canonicalizations[0]) @@ -214,8 +205,35 @@ func Sign(email *[]byte, options sigOptions) error { return nil } +// verify verifies an email an return +// state: SUCCESS or PERMFAIL or TEMPFAIL or NOTSIGNED +// msg: a complementary message (if needed) +// error: if an error occurs during verification +func Verify(email *[]byte) (state, msg string, err error) { + // parse email + dkimHeader, err := NewFromEmail(email) + if err != nil { + if err == ErrDkimHeaderNotFound { + return "NOTSIGNED", ErrDkimHeaderNotFound.Error(), nil + } else { + return "PERMFAIL", err.Error(), err + } + } + println(dkimHeader) + + // // Normalize + headers, body, err := canonicalize(email, dkimHeader.MessageCanonicalization, dkimHeader.Headers) + if err != nil { + return "PERMFAIL", err.Error(), err + } + + // HERE + + return "SUCCESS", "", nil +} + // canonicalize returns canonicalized version of header and body -func canonicalize(email *[]byte, options sigOptions) (headers, body []byte, err error) { +func canonicalize(email *[]byte, cano string, h []string) (headers, body []byte, err error) { body = []byte{} rxReduceWS := regexp.MustCompile(`[ \t]+`) @@ -231,7 +249,7 @@ func canonicalize(email *[]byte, options sigOptions) (headers, body []byte, err parts[1] = []byte{13, 10} } - canonicalizations := strings.Split(options.Canonicalization, "/") + canonicalizations := strings.Split(cano, "/") // canonicalyze header headersList := list.New() @@ -258,7 +276,7 @@ func canonicalize(email *[]byte, options sigOptions) (headers, body []byte, err var match *list.Element headersToKeepList := list.New() - for _, headerToKeep := range options.Headers { + for _, headerToKeep := range h { match = nil headerToKeepToLower := strings.ToLower(headerToKeep) for e := headersList.Front(); e != nil; e = e.Next() { @@ -323,7 +341,7 @@ func canonicalize(email *[]byte, options sigOptions) (headers, body []byte, err // canonicalizeHeader returns canonicalized version of header func canonicalizeHeader(header string, algo string) (string, error) { - rxReduceWS := regexp.MustCompile(`[ \t]+`) + //rxReduceWS := regexp.MustCompile(`[ \t]+`) if algo == "simple" { // The "simple" header canonicalization algorithm does not change header // fields in any way. Header fields MUST be presented to the signing or @@ -360,11 +378,36 @@ func canonicalizeHeader(header string, algo string) (string, error) { } k := strings.ToLower(kv[0]) k = strings.TrimSpace(k) - v := strings.Replace(kv[1], "\n", "", -1) - v = strings.Replace(v, "\r", "", -1) - v = rxReduceWS.ReplaceAllString(v, " ") - v = strings.TrimSpace(v) + v := removeFWS(kv[1]) + //v = rxReduceWS.ReplaceAllString(v, " ") + //v = strings.TrimSpace(v) return k + ":" + v + CRLF, nil } return header, ErrSignBadCanonicalization } + +// removeFWS removes all FWS from string +func removeFWS(in string) string { + rxReduceWS := regexp.MustCompile(`[ \t]+`) + out := strings.Replace(in, "\n", "", -1) + out = strings.Replace(out, "\r", "", -1) + out = rxReduceWS.ReplaceAllString(out, " ") + return strings.TrimSpace(out) +} + +// validateCanonicalization validate canonicalization (c flag) +func validateCanonicalization(cano string) (string, error) { + p := strings.Split(cano, "/") + if len(p) > 2 { + return "", ErrSignBadCanonicalization + } + if len(p) == 1 { + cano = cano + "/simple" + } + for _, c := range p { + if c != "simple" && c != "relaxed" { + return "", ErrSignBadCanonicalization + } + } + return cano, nil +} diff --git a/dkimHeader.go b/dkimHeader.go index bf50303..9cba0bd 100644 --- a/dkimHeader.go +++ b/dkimHeader.go @@ -1,7 +1,12 @@ package dkim import ( + "bytes" + "errors" "fmt" + "net/mail" + "net/textproto" + "strconv" "strings" "time" ) @@ -180,7 +185,16 @@ type DkimHeader struct { // in the "z=" tag. Copied header field values are for diagnostic // use. // tag z - CopiedHeaderFileds []string + CopiedHeaderFields []string + + // HeaderMailFromDomain store the raw email address of the header Mail From + // used for verifying in case of multiple DKIM header (we will prioritise + // header with d = mail from domain) + //HeaderMailFromDomain string + + // RawForsign represents the raw part (with non canonicalization) of the header + // used for computint sig in verify process + RawForSign string } // NewDkimHeaderBySigOptions return a new DkimHeader initioalized with sigOptions value @@ -201,13 +215,165 @@ func NewDkimHeaderBySigOptions(options sigOptions) *DkimHeader { if options.SignatureExpireIn > 0 { h.SignatureExpiration = time.Now().Add(time.Duration(options.SignatureExpireIn) * time.Second) } - h.CopiedHeaderFileds = options.CopiedHeaderFileds + h.CopiedHeaderFields = options.CopiedHeaderFields return h } +// NewFromEmail return a new DkimHeader by parsing an email +// Note: according to RFC 6376 an email can have multiple DKIM Header +// in this case we return the last inserted or the last with d== mail from +func NewFromEmail(email *[]byte) (*DkimHeader, error) { + m, err := mail.ReadMessage(bytes.NewReader(*email)) + if err != nil { + return nil, err + } + + // DKIM header ? + if len(m.Header[textproto.CanonicalMIMEHeaderKey("DKIM-Signature")]) == 0 { + return nil, ErrDkimHeaderNotFound + } + + // Get mail from domain + mailFromDomain := "" + mailfrom, err := mail.ParseAddress(m.Header.Get(textproto.CanonicalMIMEHeaderKey("From"))) + if err != nil { + if err.Error() != "mail: no address" { + return nil, err + } + } else { + t := strings.SplitAfter(mailfrom.Address, "@") + if len(t) > 1 { + mailFromDomain = strings.ToLower(t[1]) + } + } + + var keep *DkimHeader + var keepErr error + for _, dk := range m.Header[textproto.CanonicalMIMEHeaderKey("DKIM-Signature")] { + parsed, err := parseDkHeader(dk) + // if malformed dkim header try next + if err != nil { + keepErr = err + continue + } + // Keep first dkim headers + if keep == nil { + keep = parsed + } + // if d flag == domain keep this header and return + if mailFromDomain == parsed.Domain { + return parsed, nil + } + } + if keep == nil { + return nil, keepErr + } + return keep, nil +} + +func parseDkHeader(header string) (dkh *DkimHeader, err error) { + dkh = new(DkimHeader) + + t := strings.LastIndex(header, "b=") + if t == -1 { + return nil, ErrDkimHeaderBTagNotFound + } + dkh.RawForSign = header[0 : t+2] + // Mandatory + mandatoryFlags := make(map[string]bool, 7) //(b'v', b'a', b'b', b'bh', b'd', b'h', b's') + mandatoryFlags["v"] = false + mandatoryFlags["a"] = false + mandatoryFlags["b"] = false + mandatoryFlags["bh"] = false + mandatoryFlags["d"] = false + mandatoryFlags["h"] = false + mandatoryFlags["s"] = false + + // default values + dkh.MessageCanonicalization = "simple/simple" + dkh.QueryMethods = []string{"dns/txt"} + + fs := strings.Split(header, ";") + for _, f := range fs { + flagData := strings.SplitN(f, "=", 2) + flag := strings.ToLower(strings.TrimSpace(flagData[0])) + data := strings.TrimSpace(flagData[1]) + switch flag { + case "v": + if data != "1" { + return nil, ErrDkimVersionUnsuported + } + dkh.Version = data + mandatoryFlags["v"] = true + case "a": + dkh.Algorithm = strings.ToLower(data) + if dkh.Algorithm != "rsa-sha1" && dkh.Algorithm != "rsa-sha256" { + return nil, ErrSignBadAlgo + } + mandatoryFlags["a"] = true + case "b": + dkh.SignatureData = removeFWS(data) + mandatoryFlags["b"] = true + case "bh": + dkh.BodyHash = removeFWS(data) + mandatoryFlags["bh"] = true + case "d": + dkh.Domain = strings.ToLower(data) + mandatoryFlags["d"] = true + case "h": + data = strings.ToLower(data) + dkh.Headers = strings.Split(data, ":") + mandatoryFlags["h"] = true + case "s": + dkh.Selector = strings.ToLower(data) + mandatoryFlags["s"] = true + case "c": + dkh.MessageCanonicalization, err = validateCanonicalization(strings.ToLower(data)) + if err != nil { + return + } + case "i": + dkh.Auid = data + case "l": + ui, err := strconv.ParseUint(data, 10, 32) + if err != nil { + return nil, err + } + dkh.BodyLength = uint(ui) + case "q": + dkh.QueryMethods = strings.Split(data, ":") + case "t": + ts, err := strconv.ParseInt(data, 10, 64) + if err != nil { + return nil, err + } + dkh.SignatureTimestamp = time.Unix(ts, 0) + + case "x": + ts, err := strconv.ParseInt(data, 10, 64) + if err != nil { + return nil, err + } + dkh.SignatureExpiration = time.Unix(ts, 0) + case "z": + dkh.CopiedHeaderFields = strings.Split(data, "|") + } + } + + // All mandatory flags are in ? + for f, p := range mandatoryFlags { + if !p { + return nil, errors.New("missing '" + f + "' flag in DKIM header") + } + } + + return dkh, nil + +} + // GetHeaderBase return base header for signers // Todo: some refactoring needed... -func (d *DkimHeader) GetHeaderBase(bodyHash string) string { +func (d *DkimHeader) GetHeaderBaseForSigning(bodyHash string) string { h := "DKIM-Signature: v=" + d.Version + "; a=" + d.Algorithm + "; q=" + strings.Join(d.QueryMethods, ":") + "; c=" + d.MessageCanonicalization + ";" + CRLF + TAB subh := "s=" + d.Selector + ";" if len(subh)+len(d.Domain)+4 > MaxHeaderLineLength { diff --git a/dkim_test.go b/dkim_test.go index 5384aff..74296e3 100644 --- a/dkim_test.go +++ b/dkim_test.go @@ -52,6 +52,23 @@ var emailBase = "Received: (qmail 28277 invoked from network); 1 May 2015 09:43: "-- " + CRLF + "Toorop" + CRLF + CRLF + CRLF + CRLF + CRLF + CRLF +var emailBaseNoFrom = "Received: (qmail 28277 invoked from network); 1 May 2015 09:43:37 -0000" + CRLF + + "Received: (qmail 21323 invoked from network); 1 May 2015 09:48:39 -0000" + CRLF + + "Received: from mail483.ha.ovh.net (b6.ovh.net [213.186.33.56])" + CRLF + + " by mo51.mail-out.ovh.net (Postfix) with SMTP id A6E22FF8934" + CRLF + + " for ; Mon, 4 May 2015 14:00:47 +0200 (CEST)" + CRLF + + "MIME-Version: 1.0" + CRLF + + "Date: Fri, 1 May 2015 11:48:37 +0200" + CRLF + + "Message-ID: " + CRLF + + "Subject: Test DKIM" + CRLF + + "To: =?UTF-8?Q?St=C3=A9phane_Depierrepont?= " + CRLF + + "Content-Type: text/plain; charset=UTF-8" + CRLF + CRLF + + "Hello world" + CRLF + + "line with trailing space " + CRLF + + "line with space " + CRLF + + "-- " + CRLF + + "Toorop" + CRLF + CRLF + CRLF + CRLF + CRLF + CRLF + var headerSimple = "From: =?UTF-8?Q?St=C3=A9phane_Depierrepont?= " + CRLF + "Date: Fri, 1 May 2015 11:48:37 +0200" + CRLF + "MIME-Version: 1.0" + CRLF + @@ -94,10 +111,44 @@ var signedSimpleSimple = "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=simple " AKF2TcTLZ++1nalq+djU+/aP4KYQd4RWWFBjkxDzvCH4bvB1M5AGp4Qz9ldmdMQBWOvvSp" + CRLF + " DIpJW4XNA/uqLSswtjCYbJsSg9Ywv1o=" + CRLF + emailBase +var signedNoFrom = "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=simple/simple;" + CRLF + + " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + + " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + + " b=SoEhlu1Emm2ASqo8jMhz6FIf2nNHt3ouY4Av/pFFEkQ048RqUFP437ap7RbtL2wh0N3Kkm" + CRLF + + " AKF2TcTLZ++1nalq+djU+/aP4KYQd4RWWFBjkxDzvCH4bvB1M5AGp4Qz9ldmdMQBWOvvSp" + CRLF + + " DIpJW4XNA/uqLSswtjCYbJsSg9Ywv1o=" + CRLF + emailBaseNoFrom + +var signedMissingFlag = "DKIM-Signature: v=1; q=dns/txt; c=simple/simple;" + CRLF + + " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + + " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + + " b=SoEhlu1Emm2ASqo8jMhz6FIf2nNHt3ouY4Av/pFFEkQ048RqUFP437ap7RbtL2wh0N3Kkm" + CRLF + + " AKF2TcTLZ++1nalq+djU+/aP4KYQd4RWWFBjkxDzvCH4bvB1M5AGp4Qz9ldmdMQBWOvvSp" + CRLF + + " DIpJW4XNA/uqLSswtjCYbJsSg9Ywv1o=" + CRLF + emailBase + +var signedBadAlgo = "DKIM-Signature: v=1; a=rsa-shasha; q=dns/txt; c=simple/simple;" + CRLF + + " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + + " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + + " b=SoEhlu1Emm2ASqo8jMhz6FIf2nNHt3ouY4Av/pFFEkQ048RqUFP437ap7RbtL2wh0N3Kkm" + CRLF + + " AKF2TcTLZ++1nalq+djU+/aP4KYQd4RWWFBjkxDzvCH4bvB1M5AGp4Qz9ldmdMQBWOvvSp" + CRLF + + " DIpJW4XNA/uqLSswtjCYbJsSg9Ywv1o=" + CRLF + emailBase + +var signedDouble = "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=simple/simple;" + CRLF + + " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + + " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + + " b=SoEhlu1Emm2ASqo8jMhz6FIf2nNHt3ouY4Av/pFFEkQ048RqUFP437ap7RbtL2wh0N3Kkm" + CRLF + + " AKF2TcTLZ++1nalq+djU+/aP4KYQd4RWWFBjkxDzvCH4bvB1M5AGp4Qz9ldmdMQBWOvvSp" + CRLF + + " DIpJW4XNA/uqLSswtjCYbJsSg9Ywv1o=" + CRLF + + "DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed;" + CRLF + + " s=test; d=tmail.io; l=5; h=from:date:mime-version:received:received;" + CRLF + + " bh=GF+NsyJx/iX1Yab8k4suJkMG7DBO2lGAB9F2SCY4GWk=;" + CRLF + + " b=byhiFWd0lAM1sqD1tl8S1DZtKNqgiEZp8jrGds6RRydnZkdX9rCPeL0Q5MYWBQ/JmQrml5" + CRLF + + " pIghLwl/EshDBmNy65O6qO8pSSGgZmM3T7SRLMloex8bnrBJ4KSYcHV46639gVEWcBOKW0" + CRLF + + " h1djZu2jaTuxGeJzlFVtw3Arf2B93cc=" + CRLF + emailBase + func Test_NewSigOptions(t *testing.T) { options := NewSigOptions() assert.Equal(t, "rsa-sha256", options.Algo) - assert.Equal(t, "relaxed/simple", options.Canonicalization) + assert.Equal(t, "simple/simple", options.Canonicalization) } /*func Test_SignConfig(t *testing.T) { @@ -203,3 +254,32 @@ func Test_Sign(t *testing.T) { assert.Equal(t, []byte(signedSimpleSimple), emailSimple) } + +func Test_Verify(t *testing.T) { + // no DKIM header + email := []byte(emailBase) + status, msg, err := Verify(&email) + assert.NoError(t, err) + assert.Equal(t, "NOTSIGNED", status) + assert.Equal(t, ErrDkimHeaderNotFound.Error(), msg) + + // No From + email = []byte(signedNoFrom) + status, msg, err = Verify(&email) + assert.NoError(t, err) + assert.Equal(t, "SUCCESS", status) + + // missing mandatory 'a' flag + email = []byte(signedMissingFlag) + status, msg, err = Verify(&email) + assert.Error(t, err) + assert.Equal(t, "PERMFAIL", status) + assert.Equal(t, "missing 'a' flag in DKIM header", msg) + + // missing bad algo + email = []byte(signedBadAlgo) + status, msg, err = Verify(&email) + assert.Error(t, err) + assert.Equal(t, "PERMFAIL", status) + assert.Equal(t, ErrSignBadAlgo.Error(), msg) +} diff --git a/errors.go b/errors.go index 5920a05..2195b26 100644 --- a/errors.go +++ b/errors.go @@ -34,4 +34,15 @@ var ( // ErrBadDKimTagLBodyTooShort ErrBadDKimTagLBodyTooShort = errors.New("bad tag l or bodyLength option. Body length < l value") + + // ErrDkimHeaderNotFound when there's no DKIM-Signature header in an email we have to verify + ErrDkimHeaderNotFound = errors.New("no DKIM-Signature header field found ") + + // ErrDkimHeaderBTagNotFound when there's no b tag + ErrDkimHeaderBTagNotFound = errors.New("no tag 'b' found in dkim header") + + ErrDkimHeaderMissingTagV = errors.New("no tag 'v' found in dkim header") + + // Version not supported + ErrDkimVersionUnsuported = errors.New("unsuported DKIM version") )