├── authres ├── authres.go ├── format_test.go ├── example_test.go ├── parse_test.go ├── msgauth_test.go ├── format.go └── parse.go ├── go.mod ├── .gitignore ├── cmd ├── dmarc-lookup │ └── main.go ├── dkim-verify │ └── main.go ├── dkim-keygen │ └── main.go └── dkim-milter │ └── main.go ├── .build.yml ├── dkim ├── dkim.go ├── example_test.go ├── query_test.go ├── dkim_test.go ├── sign_ed25519_test.go ├── header.go ├── header_test.go ├── canonical_test.go ├── canonical.go ├── sign_test.go ├── query.go ├── sign.go ├── verify_test.go └── verify.go ├── LICENSE ├── README.md ├── dmarc ├── dmarc.go └── lookup.go └── go.sum /authres/authres.go: -------------------------------------------------------------------------------- 1 | // Package authres parses and formats Authentication-Results 2 | // 3 | // Authentication-Results header fields are standardized in RFC 7601. 4 | package authres 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/emersion/go-msgauth 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/emersion/go-milter v0.4.1 7 | golang.org/x/crypto v0.37.0 8 | ) 9 | 10 | require github.com/emersion/go-message v0.18.2 // indirect 11 | -------------------------------------------------------------------------------- /authres/format_test.go: -------------------------------------------------------------------------------- 1 | package authres 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFormat(t *testing.T) { 8 | for _, test := range msgauthTests { 9 | v := Format(test.identifier, test.results) 10 | if v != test.value { 11 | t.Errorf("Expected formatted header field to be \n%q\n but got \n%q", test.value, v) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /cmd/dmarc-lookup/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | 7 | "github.com/emersion/go-msgauth/dmarc" 8 | ) 9 | 10 | func main() { 11 | flag.Parse() 12 | 13 | domain := flag.Arg(0) 14 | if domain == "" { 15 | log.Fatal("usage: dmarc-lookup ") 16 | } 17 | 18 | rec, err := dmarc.Lookup(domain) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | log.Printf("%#v\n", rec) 24 | } 25 | -------------------------------------------------------------------------------- /.build.yml: -------------------------------------------------------------------------------- 1 | image: alpine/latest 2 | packages: 3 | - go 4 | sources: 5 | - https://github.com/emersion/go-msgauth 6 | artifacts: 7 | - coverage.html 8 | tasks: 9 | - build: | 10 | cd go-msgauth 11 | go build -v ./... 12 | - test: | 13 | cd go-msgauth 14 | go test -coverprofile=coverage.txt -covermode=atomic ./... 15 | - coverage: | 16 | cd go-msgauth 17 | go tool cover -html=coverage.txt -o ~/coverage.html 18 | -------------------------------------------------------------------------------- /cmd/dkim-verify/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/emersion/go-msgauth/dkim" 8 | ) 9 | 10 | func main() { 11 | verifications, err := dkim.Verify(os.Stdin) 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | 16 | for _, v := range verifications { 17 | if v.Err == nil { 18 | log.Printf("Valid signature for %v", v.Domain) 19 | } else { 20 | log.Printf("Invalid signature for %v: %v", v.Domain, v.Err) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /authres/example_test.go: -------------------------------------------------------------------------------- 1 | package authres_test 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/emersion/go-msgauth/authres" 7 | ) 8 | 9 | func Example() { 10 | // Format 11 | results := []authres.Result{ 12 | &authres.SPFResult{Value: authres.ResultPass, From: "example.net"}, 13 | &authres.AuthResult{Value: authres.ResultPass, Auth: "sender@example.com"}, 14 | } 15 | s := authres.Format("example.com", results) 16 | log.Println(s) 17 | 18 | // Parse 19 | identifier, results, err := authres.Parse(s) 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | 24 | log.Println(identifier, results) 25 | } 26 | -------------------------------------------------------------------------------- /dkim/dkim.go: -------------------------------------------------------------------------------- 1 | // Package dkim creates and verifies DKIM signatures, as specified in RFC 6376. 2 | // 3 | // # FAQ 4 | // 5 | // Why can't I verify a [net/mail.Message] directly? A [net/mail.Message] 6 | // header is already parsed, and whitespace characters (especially continuation 7 | // lines) are removed. Thus, the signature computed from the parsed header is 8 | // not the same as the one computed from the raw header. 9 | // 10 | // How can I publish my public key? You have to add a TXT record to your DNS 11 | // zone. See [RFC 6376 appendix C]. You can use the dkim-keygen tool included 12 | // in go-msgauth to generate the key and the TXT record. 13 | // 14 | // [RFC 6376 appendix C]: https://tools.ietf.org/html/rfc6376#appendix-C 15 | package dkim 16 | 17 | import ( 18 | "time" 19 | ) 20 | 21 | var now = time.Now 22 | 23 | const headerFieldName = "DKIM-Signature" 24 | -------------------------------------------------------------------------------- /dkim/example_test.go: -------------------------------------------------------------------------------- 1 | package dkim_test 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "log" 7 | "strings" 8 | 9 | "github.com/emersion/go-msgauth/dkim" 10 | ) 11 | 12 | var ( 13 | mailString string 14 | privateKey crypto.Signer 15 | ) 16 | 17 | func ExampleSign() { 18 | r := strings.NewReader(mailString) 19 | 20 | options := &dkim.SignOptions{ 21 | Domain: "example.org", 22 | Selector: "brisbane", 23 | Signer: privateKey, 24 | } 25 | 26 | var b bytes.Buffer 27 | if err := dkim.Sign(&b, r, options); err != nil { 28 | log.Fatal(err) 29 | } 30 | } 31 | 32 | func ExampleVerify() { 33 | r := strings.NewReader(mailString) 34 | 35 | verifications, err := dkim.Verify(r) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | 40 | for _, v := range verifications { 41 | if v.Err == nil { 42 | log.Println("Valid signature for:", v.Domain) 43 | } else { 44 | log.Println("Invalid signature for:", v.Domain, v.Err) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 emersion 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-msgauth 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/emersion/go-msgauth.svg)](https://pkg.go.dev/github.com/emersion/go-msgauth) 4 | 5 | A Go library and tools to authenticate e-mails. 6 | 7 | ## Libraries 8 | 9 | * [`dkim`]: create and verify [DKIM signatures][DKIM] 10 | * [`authres`]: create and parse [Authentication-Results header fields][Authentication-Results] 11 | * [`dmarc`]: fetch [DMARC] records 12 | 13 | ## Tools 14 | 15 | A few tools are included in go-msgauth: 16 | 17 | - `dkim-keygen`: generate a DKIM key 18 | - `dkim-milter`: a mail filter to sign and verify DKIM signatures 19 | - `dkim-verify`: verify a DKIM-signed email 20 | - `dmarc-lookup`: lookup the DMARC policy of a domain 21 | 22 | ## License 23 | 24 | MIT 25 | 26 | [DKIM]: https://tools.ietf.org/html/rfc6376 27 | [Authentication-Results]: https://tools.ietf.org/html/rfc7601 28 | [DMARC]: https://tools.ietf.org/html/rfc7489 29 | [`dkim`]: https://pkg.go.dev/github.com/emersion/go-msgauth/dkim 30 | [`authres`]: https://pkg.go.dev/github.com/emersion/go-msgauth/authres 31 | [`dmarc`]: https://pkg.go.dev/github.com/emersion/go-msgauth/dmarc 32 | -------------------------------------------------------------------------------- /dmarc/dmarc.go: -------------------------------------------------------------------------------- 1 | // Package dmarc implements DMARC as specified in RFC 7489. 2 | package dmarc 3 | 4 | import ( 5 | "time" 6 | ) 7 | 8 | type AlignmentMode string 9 | 10 | const ( 11 | AlignmentStrict AlignmentMode = "s" 12 | AlignmentRelaxed = "r" 13 | ) 14 | 15 | type FailureOptions int 16 | 17 | const ( 18 | FailureAll FailureOptions = 1 << iota // "0" 19 | FailureAny // "1" 20 | FailureDKIM // "d" 21 | FailureSPF // "s" 22 | ) 23 | 24 | type Policy string 25 | 26 | const ( 27 | PolicyNone Policy = "none" 28 | PolicyQuarantine = "quarantine" 29 | PolicyReject = "reject" 30 | ) 31 | 32 | type ReportFormat string 33 | 34 | const ( 35 | ReportFormatAFRF ReportFormat = "afrf" 36 | ) 37 | 38 | // Record is a DMARC record, as defined in RFC 7489 section 6.3. 39 | type Record struct { 40 | DKIMAlignment AlignmentMode // "adkim" 41 | SPFAlignment AlignmentMode // "aspf" 42 | FailureOptions FailureOptions // "fo" 43 | Policy Policy // "p" 44 | Percent *int // "pct" 45 | ReportFormat []ReportFormat // "rf" 46 | ReportInterval time.Duration // "ri" 47 | ReportURIAggregate []string // "rua" 48 | ReportURIFailure []string // "ruf" 49 | SubdomainPolicy Policy // "sp" 50 | } 51 | -------------------------------------------------------------------------------- /dkim/query_test.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const dnsRawRSAPublicKey = "v=DKIM1; p=MIGJAoGBALVI635dLK4cJJAH3Lx6upo3X/L" + 8 | "m1tQz3mezcWTA3BUBnyIsdnRf57aD5BtNmhPrYYDlWlzw3" + 9 | "UgnKisIxktkk5+iMQMlFtAS10JB8L3YadXNJY+JBcbeSi5" + 10 | "TgJe4WFzNgW95FWDAuSTRXSWZfA/8xjflbTLDx0euFZOM7" + 11 | "C4T0GwLAgMBAAE=" 12 | 13 | const dnsPublicKey = "v=DKIM1; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ" + 14 | "KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt" + 15 | "IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v" + 16 | "/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi" + 17 | "tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB" 18 | 19 | const dnsEd25519PublicKey = "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=" 20 | 21 | func init() { 22 | queryMethods["dns/txt"] = queryTest 23 | } 24 | 25 | func queryTest(domain, selector string, txtLookup txtLookupFunc) (*queryResult, error) { 26 | record := selector + "._domainkey." + domain 27 | switch record { 28 | case "brisbane._domainkey.example.com", "brisbane._domainkey.example.org", "test._domainkey.football.example.com": 29 | return parsePublicKey(dnsPublicKey) 30 | case "newengland._domainkey.example.com": 31 | return parsePublicKey(dnsRawRSAPublicKey) 32 | case "brisbane._domainkey.football.example.com": 33 | return parsePublicKey(dnsEd25519PublicKey) 34 | } 35 | return nil, fmt.Errorf("unknown test DNS record %v", record) 36 | } 37 | -------------------------------------------------------------------------------- /authres/parse_test.go: -------------------------------------------------------------------------------- 1 | package authres 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | var parseTests = []msgauthTest{ 9 | { 10 | value: "", 11 | identifier: "", 12 | results: nil, 13 | }, 14 | { 15 | value: "example.com 1; none", 16 | identifier: "example.com", 17 | results: nil, 18 | }, 19 | { 20 | value: "example.com; \r\n" + 21 | " \t spf=pass smtp.mailfrom=example.net", 22 | identifier: "example.com", 23 | results: []Result{ 24 | &SPFResult{Value: ResultPass, From: "example.net"}, 25 | }, 26 | }, 27 | { 28 | value: "example.com;" + 29 | " auth=pass (cram-md5) smtp.auth=sender@example.com;", 30 | identifier: "example.com", 31 | results: []Result{ 32 | &AuthResult{Value: ResultPass, Auth: "sender@example.com"}, 33 | }, 34 | }, 35 | } 36 | 37 | func TestParse(t *testing.T) { 38 | for _, test := range append(msgauthTests, parseTests...) { 39 | identifier, results, err := Parse(test.value) 40 | if err != nil { 41 | t.Errorf("Expected no error when parsing header, got: %v", err) 42 | } else if test.identifier != identifier { 43 | t.Errorf("Expected identifier to be %q, but got %q", test.identifier, identifier) 44 | } else if len(test.results) != len(results) { 45 | t.Errorf("Expected number of results to be %v, but got %v", len(test.results), len(results)) 46 | } else { 47 | for i := 0; i < len(results); i++ { 48 | if !reflect.DeepEqual(test.results[i], results[i]) { 49 | t.Errorf("Expected result to be \n%v\n but got \n%v", test.results[i], results[i]) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /dkim/dkim_test.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "crypto/rsa" 5 | "crypto/x509" 6 | "encoding/base64" 7 | "encoding/pem" 8 | "time" 9 | 10 | "golang.org/x/crypto/ed25519" 11 | ) 12 | 13 | const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY----- 14 | MIICXwIBAAKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYtIxN2SnFC 15 | jxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v/RtdC2UzJ1lWT947qR+Rcac2gb 16 | to/NMqJ0fzfVjH4OuKhitdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB 17 | AoGBALmn+XwWk7akvkUlqb+dOxyLB9i5VBVfje89Teolwc9YJT36BGN/l4e0l6QX 18 | /1//6DWUTB3KI6wFcm7TWJcxbS0tcKZX7FsJvUz1SbQnkS54DJck1EZO/BLa5ckJ 19 | gAYIaqlA9C0ZwM6i58lLlPadX/rtHb7pWzeNcZHjKrjM461ZAkEA+itss2nRlmyO 20 | n1/5yDyCluST4dQfO8kAB3toSEVc7DeFeDhnC1mZdjASZNvdHS4gbLIA1hUGEF9m 21 | 3hKsGUMMPwJBAPW5v/U+AWTADFCS22t72NUurgzeAbzb1HWMqO4y4+9Hpjk5wvL/ 22 | eVYizyuce3/fGke7aRYw/ADKygMJdW8H/OcCQQDz5OQb4j2QDpPZc0Nc4QlbvMsj 23 | 7p7otWRO5xRa6SzXqqV3+F0VpqvDmshEBkoCydaYwc2o6WQ5EBmExeV8124XAkEA 24 | qZzGsIxVP+sEVRWZmW6KNFSdVUpk3qzK0Tz/WjQMe5z0UunY9Ax9/4PVhp/j61bf 25 | eAYXunajbBSOLlx4D+TunwJBANkPI5S9iylsbLs6NkaMHV6k5ioHBBmgCak95JGX 26 | GMot/L2x0IYyMLAz6oLWh2hm7zwtb0CgOrPo1ke44hFYnfc= 27 | -----END RSA PRIVATE KEY----- 28 | ` 29 | 30 | const testEd25519SeedBase64 = "nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=" 31 | 32 | var ( 33 | testPrivateKey *rsa.PrivateKey 34 | testEd25519PrivateKey ed25519.PrivateKey 35 | ) 36 | 37 | func init() { 38 | block, _ := pem.Decode([]byte(testPrivateKeyPEM)) 39 | var err error 40 | testPrivateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | ed25519Seed, err := base64.StdEncoding.DecodeString(testEd25519SeedBase64) 46 | if err != nil { 47 | panic(err) 48 | } 49 | testEd25519PrivateKey = ed25519.NewKeyFromSeed(ed25519Seed) 50 | 51 | now = func() time.Time { 52 | return time.Unix(424242, 0) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /dkim/sign_ed25519_test.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "bytes" 5 | "math/rand" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | const signedEd25519MailString = "DKIM-Signature: a=ed25519-sha256; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;" + "\r\n" + 11 | " " + "c=simple/simple; d=football.example.com;" + "\r\n" + 12 | " " + "h=From:To:Subject:Date:Message-ID; s=brisbane; t=424242; v=1;" + "\r\n" + 13 | " " + "b=k1LzRxs9/DfN/whlMICYKNIJhqUSmup0d5yw8tpi+Cfiqe6I3oSBmJ+HEp+moWy7/XvcUY/t" + "\r\n" + 14 | " " + "ERHc3D2m7vw1AA==" + "\r\n" + 15 | mailHeaderString + 16 | "\r\n" + 17 | mailBodyString 18 | 19 | func init() { 20 | randReader = rand.New(rand.NewSource(42)) 21 | } 22 | 23 | func TestSignEd25519(t *testing.T) { 24 | r := strings.NewReader(mailString) 25 | options := &SignOptions{ 26 | Domain: "football.example.com", 27 | Selector: "brisbane", 28 | Signer: testEd25519PrivateKey, 29 | } 30 | 31 | var b bytes.Buffer 32 | if err := Sign(&b, r, options); err != nil { 33 | t.Fatal("Expected no error while signing mail, got:", err) 34 | } 35 | 36 | if s := b.String(); s != signedEd25519MailString { 37 | t.Errorf("Expected signed message to be \n%v\n but got \n%v", signedEd25519MailString, s) 38 | } 39 | } 40 | 41 | func TestSignAndVerifyEd25519(t *testing.T) { 42 | r := strings.NewReader(mailString) 43 | options := &SignOptions{ 44 | Domain: "football.example.com", 45 | Selector: "brisbane", 46 | Signer: testEd25519PrivateKey, 47 | } 48 | 49 | var b bytes.Buffer 50 | if err := Sign(&b, r, options); err != nil { 51 | t.Fatal("Expected no error while signing mail, got:", err) 52 | } 53 | 54 | verifications, err := Verify(&b) 55 | if err != nil { 56 | t.Fatalf("Expected no error while verifying signature, got: %v", err) 57 | } 58 | if len(verifications) != 1 { 59 | t.Error("Expected exactly one verification") 60 | } else { 61 | v := verifications[0] 62 | if err := v.Err; err != nil { 63 | t.Errorf("Expected no error when verifying signature, got: %v", err) 64 | } 65 | if v.Domain != options.Domain { 66 | t.Errorf("Expected domain to be %q but got %q", options.Domain, v.Domain) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /authres/msgauth_test.go: -------------------------------------------------------------------------------- 1 | package authres 2 | 3 | type msgauthTest struct { 4 | value string 5 | identifier string 6 | results []Result 7 | } 8 | 9 | var msgauthTests = []msgauthTest{ 10 | { 11 | value: "example.org; none", 12 | identifier: "example.org", 13 | results: nil, 14 | }, 15 | { 16 | value: "example.com; dkim=none ", 17 | identifier: "example.com", 18 | results: []Result{ 19 | &DKIMResult{Value: ResultNone}, 20 | }, 21 | }, 22 | { 23 | value: "example.com;" + 24 | " spf=pass smtp.mailfrom=example.net", 25 | identifier: "example.com", 26 | results: []Result{ 27 | &SPFResult{Value: ResultPass, From: "example.net"}, 28 | }, 29 | }, 30 | { 31 | value: "example.com;" + 32 | " spf=fail reason=bad smtp.mailfrom=example.net", 33 | identifier: "example.com", 34 | results: []Result{ 35 | &SPFResult{Value: ResultFail, Reason: "bad", From: "example.net"}, 36 | }, 37 | }, 38 | { 39 | value: "example.com;" + 40 | " auth=pass smtp.auth=sender@example.com;" + 41 | " spf=pass smtp.mailfrom=example.com", 42 | identifier: "example.com", 43 | results: []Result{ 44 | &AuthResult{Value: ResultPass, Auth: "sender@example.com"}, 45 | &SPFResult{Value: ResultPass, From: "example.com"}, 46 | }, 47 | }, 48 | { 49 | value: "example.com;" + 50 | " sender-id=pass header.from=example.com", 51 | identifier: "example.com", 52 | results: []Result{ 53 | &SenderIDResult{Value: ResultPass, HeaderKey: "from", HeaderValue: "example.com"}, 54 | }, 55 | }, 56 | { 57 | value: "example.com;" + 58 | " sender-id=hardfail header.from=example.com;" + 59 | " dkim=pass header.i=sender@example.com", 60 | identifier: "example.com", 61 | results: []Result{ 62 | &SenderIDResult{Value: ResultHardFail, HeaderKey: "from", HeaderValue: "example.com"}, 63 | &DKIMResult{Value: ResultPass, Identifier: "sender@example.com"}, 64 | }, 65 | }, 66 | { 67 | value: "example.com;" + 68 | " auth=pass smtp.auth=sender@example.com;" + 69 | " spf=hardfail smtp.mailfrom=example.com", 70 | identifier: "example.com", 71 | results: []Result{ 72 | &AuthResult{Value: ResultPass, Auth: "sender@example.com"}, 73 | &SPFResult{Value: ResultHardFail, From: "example.com"}, 74 | }, 75 | }, 76 | { 77 | value: "example.com;" + 78 | " dkim=pass header.i=@mail-router.example.net;" + 79 | " dkim=fail header.i=@newyork.example.com", 80 | identifier: "example.com", 81 | results: []Result{ 82 | &DKIMResult{Value: ResultPass, Identifier: "@mail-router.example.net"}, 83 | &DKIMResult{Value: ResultFail, Identifier: "@newyork.example.com"}, 84 | }, 85 | }, 86 | } 87 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= 2 | github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= 3 | github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= 4 | github.com/emersion/go-milter v0.4.1 h1:gLs9QD0zEHF8omgEw8M+aGz6iwBNpWLAcwgSur0ra4M= 5 | github.com/emersion/go-milter v0.4.1/go.mod h1:erCQVl0mH4SX9jEvwe+wyndit0rQtmvMLH86V6NGtkI= 6 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 7 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 8 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 9 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 10 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 11 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 12 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 13 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 14 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 15 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 16 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 17 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 18 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 19 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 20 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 21 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 22 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 25 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 26 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 27 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 28 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 29 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 30 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 31 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 32 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 33 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 34 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 35 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 36 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 37 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 38 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 39 | -------------------------------------------------------------------------------- /dkim/header.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/textproto" 10 | "sort" 11 | "strings" 12 | ) 13 | 14 | const crlf = "\r\n" 15 | 16 | type header []string 17 | 18 | func readHeader(r *bufio.Reader) (header, error) { 19 | tr := textproto.NewReader(r) 20 | 21 | var h header 22 | for { 23 | l, err := tr.ReadLine() 24 | if err != nil { 25 | return h, fmt.Errorf("failed to read header: %v", err) 26 | } 27 | 28 | if len(l) == 0 { 29 | break 30 | } else if len(h) > 0 && (l[0] == ' ' || l[0] == '\t') { 31 | // This is a continuation line 32 | h[len(h)-1] += l + crlf 33 | } else { 34 | h = append(h, l+crlf) 35 | } 36 | } 37 | 38 | return h, nil 39 | } 40 | 41 | func writeHeader(w io.Writer, h header) error { 42 | for _, kv := range h { 43 | if _, err := w.Write([]byte(kv)); err != nil { 44 | return err 45 | } 46 | } 47 | _, err := w.Write([]byte(crlf)) 48 | return err 49 | } 50 | 51 | func foldHeaderField(kv string) string { 52 | buf := bytes.NewBufferString(kv) 53 | 54 | line := make([]byte, 75) // 78 - len("\r\n\s") 55 | first := true 56 | var fold strings.Builder 57 | for len, err := buf.Read(line); err != io.EOF; len, err = buf.Read(line) { 58 | if first { 59 | first = false 60 | } else { 61 | fold.WriteString("\r\n ") 62 | } 63 | fold.Write(line[:len]) 64 | } 65 | 66 | return fold.String() + crlf 67 | } 68 | 69 | func parseHeaderField(s string) (string, string) { 70 | key, value, _ := strings.Cut(s, ":") 71 | return strings.TrimSpace(key), strings.TrimSpace(value) 72 | } 73 | 74 | func parseHeaderParams(s string) (map[string]string, error) { 75 | pairs := strings.Split(s, ";") 76 | params := make(map[string]string) 77 | for _, s := range pairs { 78 | key, value, ok := strings.Cut(s, "=") 79 | if !ok { 80 | if strings.TrimSpace(s) == "" { 81 | continue 82 | } 83 | return params, errors.New("dkim: malformed header params") 84 | } 85 | 86 | trimmedKey := strings.TrimSpace(key) 87 | _, present := params[trimmedKey] 88 | if present { 89 | return params, errors.New("dkim: duplicate tag name") 90 | } 91 | params[trimmedKey] = strings.TrimSpace(value) 92 | } 93 | return params, nil 94 | } 95 | 96 | func formatHeaderParams(headerFieldName string, params map[string]string) string { 97 | keys, bvalue, bfound := sortParams(params) 98 | 99 | s := headerFieldName + ":" 100 | var line string 101 | 102 | for _, k := range keys { 103 | v := params[k] 104 | nextLength := 3 + len(line) + len(v) + len(k) 105 | if nextLength > 75 { 106 | s += line + crlf 107 | line = "" 108 | } 109 | line = fmt.Sprintf("%v %v=%v;", line, k, v) 110 | } 111 | 112 | if line != "" { 113 | s += line 114 | } 115 | 116 | if bfound { 117 | bfiled := foldHeaderField(" b=" + bvalue) 118 | s += crlf + bfiled 119 | } 120 | 121 | return s 122 | } 123 | 124 | func sortParams(params map[string]string) ([]string, string, bool) { 125 | keys := make([]string, 0, len(params)) 126 | bfound := false 127 | var bvalue string 128 | for k := range params { 129 | if k == "b" { 130 | bvalue = params["b"] 131 | bfound = true 132 | } else { 133 | keys = append(keys, k) 134 | } 135 | } 136 | sort.Strings(keys) 137 | return keys, bvalue, bfound 138 | } 139 | 140 | type headerPicker struct { 141 | h header 142 | picked map[string]int 143 | } 144 | 145 | func newHeaderPicker(h header) *headerPicker { 146 | return &headerPicker{ 147 | h: h, 148 | picked: make(map[string]int), 149 | } 150 | } 151 | 152 | func (p *headerPicker) Pick(key string) string { 153 | key = strings.ToLower(key) 154 | 155 | at := p.picked[key] 156 | for i := len(p.h) - 1; i >= 0; i-- { 157 | kv := p.h[i] 158 | k, _ := parseHeaderField(kv) 159 | 160 | if !strings.EqualFold(k, key) { 161 | continue 162 | } 163 | 164 | if at == 0 { 165 | p.picked[key]++ 166 | return kv 167 | } 168 | at-- 169 | } 170 | 171 | return "" 172 | } 173 | -------------------------------------------------------------------------------- /cmd/dkim-keygen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ed25519" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/x509" 9 | "encoding/base64" 10 | "encoding/pem" 11 | "flag" 12 | "fmt" 13 | "log" 14 | "os" 15 | "strings" 16 | ) 17 | 18 | var ( 19 | keyType string 20 | nBits int 21 | filename string 22 | readPriv bool 23 | ) 24 | 25 | func init() { 26 | flag.StringVar(&keyType, "t", "rsa", "key type (rsa, ed25519)") 27 | flag.IntVar(&nBits, "b", 3072, "number of bits in the key (only for RSA)") 28 | flag.StringVar(&filename, "f", "dkim.priv", "private key filename") 29 | flag.BoolVar(&readPriv, "y", false, "read private key and print public key") 30 | flag.Parse() 31 | } 32 | 33 | type privateKey interface { 34 | Public() crypto.PublicKey 35 | } 36 | 37 | func main() { 38 | var privKey privateKey 39 | if readPriv { 40 | privKey = readPrivKey() 41 | } else { 42 | privKey = genPrivKey() 43 | writePrivKey(privKey) 44 | } 45 | printPubKey(privKey.Public()) 46 | } 47 | 48 | func genPrivKey() privateKey { 49 | var ( 50 | privKey crypto.Signer 51 | err error 52 | ) 53 | switch keyType { 54 | case "rsa": 55 | log.Printf("Generating a %v-bit RSA key", nBits) 56 | privKey, err = rsa.GenerateKey(rand.Reader, nBits) 57 | case "ed25519": 58 | log.Printf("Generating an Ed25519 key") 59 | _, privKey, err = ed25519.GenerateKey(rand.Reader) 60 | default: 61 | log.Fatalf("Unsupported key type %q", keyType) 62 | } 63 | if err != nil { 64 | log.Fatalf("Failed to generate key: %v", err) 65 | } 66 | return privKey 67 | } 68 | 69 | func readPrivKey() privateKey { 70 | b, err := os.ReadFile(filename) 71 | if err != nil { 72 | log.Fatalf("Failed to read public key file: %v", err) 73 | } 74 | 75 | block, _ := pem.Decode(b) 76 | if block == nil { 77 | log.Fatalf("Failed to decode PEM block") 78 | } else if block.Type != "PRIVATE KEY" { 79 | log.Fatalf("Not a private key") 80 | } 81 | 82 | privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) 83 | if err != nil { 84 | log.Fatalf("Failed to parse private key: %v", err) 85 | } 86 | 87 | log.Printf("Private key read from %q", filename) 88 | return privKey.(privateKey) 89 | } 90 | 91 | func writePrivKey(privKey privateKey) { 92 | privBytes, err := x509.MarshalPKCS8PrivateKey(privKey) 93 | if err != nil { 94 | log.Fatalf("Failed to marshal private key: %v", err) 95 | } 96 | 97 | f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) 98 | if err != nil { 99 | log.Fatalf("Failed to create key file: %v", err) 100 | } 101 | defer f.Close() 102 | 103 | privBlock := pem.Block{ 104 | Type: "PRIVATE KEY", 105 | Bytes: privBytes, 106 | } 107 | if err := pem.Encode(f, &privBlock); err != nil { 108 | log.Fatalf("Failed to write key PEM block: %v", err) 109 | } 110 | if err := f.Close(); err != nil { 111 | log.Fatalf("Failed to close key file: %v", err) 112 | } 113 | log.Printf("Private key written to %q", filename) 114 | } 115 | 116 | func printPubKey(pubKey crypto.PublicKey) { 117 | var pubBytes []byte 118 | switch pubKey := pubKey.(type) { 119 | case *rsa.PublicKey: 120 | // RFC 6376 is inconsistent about whether RSA public keys should 121 | // be formatted as RSAPublicKey or SubjectPublicKeyInfo. 122 | // Erratum 3017 (https://www.rfc-editor.org/errata/eid3017) 123 | // proposes allowing both. We use SubjectPublicKeyInfo for 124 | // consistency with other implementations including opendkim, 125 | // Gmail, and Fastmail. 126 | var err error 127 | pubBytes, err = x509.MarshalPKIXPublicKey(pubKey) 128 | if err != nil { 129 | log.Fatalf("Failed to marshal public key: %v", err) 130 | } 131 | case ed25519.PublicKey: 132 | pubBytes = pubKey 133 | default: 134 | panic("unreachable") 135 | } 136 | 137 | params := []string{ 138 | "v=DKIM1", 139 | "k=" + keyType, 140 | "p=" + base64.StdEncoding.EncodeToString(pubBytes), 141 | } 142 | log.Println("Public key, to be stored in the TXT record \"._domainkey\":") 143 | fmt.Println(strings.Join(params, "; ")) 144 | } 145 | -------------------------------------------------------------------------------- /authres/format.go: -------------------------------------------------------------------------------- 1 | package authres 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | // Format formats an Authentication-Results header. 10 | func Format(identity string, results []Result) string { 11 | s := identity 12 | 13 | if len(results) == 0 { 14 | s += "; none" 15 | return s 16 | } 17 | 18 | for _, r := range results { 19 | method := resultMethod(r) 20 | value, params := r.format() 21 | 22 | s += "; " + method + "=" + string(value) + " " + formatParams(params) 23 | } 24 | 25 | return s 26 | } 27 | 28 | func resultMethod(r Result) string { 29 | switch r := r.(type) { 30 | case *AuthResult: 31 | return "auth" 32 | case *DKIMResult: 33 | return "dkim" 34 | case *DomainKeysResult: 35 | return "domainkeys" 36 | case *IPRevResult: 37 | return "iprev" 38 | case *SenderIDResult: 39 | return "sender-id" 40 | case *SPFResult: 41 | return "spf" 42 | case *DMARCResult: 43 | return "dmarc" 44 | case *GenericResult: 45 | return r.Method 46 | default: 47 | return "" 48 | } 49 | } 50 | 51 | func formatParams(params map[string]string) string { 52 | keys := make([]string, 0, len(params)) 53 | for k := range params { 54 | if k == "reason" { 55 | continue 56 | } 57 | keys = append(keys, k) 58 | } 59 | sort.Strings(keys) 60 | if params["reason"] != "" { 61 | keys = append([]string{"reason"}, keys...) 62 | } 63 | 64 | s := "" 65 | i := 0 66 | for _, k := range keys { 67 | if params[k] == "" { 68 | continue 69 | } 70 | 71 | if i > 0 { 72 | s += " " 73 | } 74 | 75 | var value string 76 | if k == "reason" { 77 | value = formatValue(params[k]) 78 | } else { 79 | value = formatPvalue(params[k]) 80 | } 81 | s += k + "=" + value 82 | i++ 83 | } 84 | 85 | return s 86 | } 87 | 88 | var tspecials = map[rune]struct{}{ 89 | '(': {}, ')': {}, '<': {}, '>': {}, '@': {}, 90 | ',': {}, ';': {}, ':': {}, '\\': {}, '"': {}, 91 | '/': {}, '[': {}, ']': {}, '?': {}, '=': {}, 92 | } 93 | 94 | func formatValue(s string) string { 95 | // value := token / quoted-string 96 | // token := 1* 98 | // tspecials := "(" / ")" / "<" / ">" / "@" / 99 | // "," / ";" / ":" / "\" / <"> 100 | // "/" / "[" / "]" / "?" / "=" 101 | // ; Must be in quoted-string, 102 | // ; to use within parameter values 103 | 104 | shouldQuote := false 105 | for _, ch := range s { 106 | if _, special := tspecials[ch]; ch <= ' ' /* SPACE or CTL */ || special { 107 | shouldQuote = true 108 | } 109 | } 110 | 111 | if shouldQuote { 112 | return `"` + strings.Replace(s, `"`, `\"`, -1) + `"` 113 | } 114 | return s 115 | } 116 | 117 | var addressOk = map[rune]struct{}{ 118 | // Most ASCII punctuation except for: 119 | // ( ) = " 120 | // as these can cause issues due to ambiguous ABNF rules. 121 | // I.e. technically mentioned characters can be left unquoted, but they can 122 | // be interpreted as parts of non-quoted parameters or comments so it is 123 | // better to quote them. 124 | '#': {}, '$': {}, '%': {}, '&': {}, 125 | '\'': {}, '*': {}, '+': {}, ',': {}, 126 | '.': {}, '/': {}, '-': {}, '@': {}, 127 | '[': {}, ']': {}, '\\': {}, '^': {}, 128 | '_': {}, '`': {}, '{': {}, '|': {}, 129 | '}': {}, '~': {}, 130 | } 131 | 132 | func formatPvalue(s string) string { 133 | // pvalue = [CFWS] ( value / [ [ local-part ] "@" ] domain-name ) 134 | // [CFWS] 135 | 136 | // Experience shows that implementers often "forget" that things can 137 | // be quoted in various places where they are usually not quoted 138 | // so we can't get away by just quoting everything. 139 | 140 | // Relevant ABNF rules are much complicated than that, but this 141 | // will catch most of the cases and we can fallback to quoting 142 | // for others. 143 | addressLike := true 144 | for _, ch := range s { 145 | if _, ok := addressOk[ch]; !unicode.IsLetter(ch) && !unicode.IsDigit(ch) && !ok { 146 | addressLike = false 147 | } 148 | } 149 | 150 | if addressLike { 151 | return s 152 | } 153 | return formatValue(s) 154 | } 155 | -------------------------------------------------------------------------------- /dkim/header_test.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "bufio" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | var headerTests = []struct { 11 | h header 12 | s string 13 | }{ 14 | { 15 | h: header{"From: \r\n"}, 16 | s: "From: \r\n\r\n", 17 | }, 18 | { 19 | h: header{ 20 | "From: \r\n", 21 | "Subject: Your Name\r\n", 22 | }, 23 | s: "From: \r\n" + 24 | "Subject: Your Name\r\n" + 25 | "\r\n", 26 | }, 27 | } 28 | 29 | func TestReadHeader(t *testing.T) { 30 | for _, test := range headerTests { 31 | r := strings.NewReader(test.s) 32 | h, err := readHeader(bufio.NewReader(r)) 33 | if err != nil { 34 | t.Fatalf("Expected no error while reading error, got: %v", err) 35 | } 36 | 37 | if !reflect.DeepEqual(h, test.h) { 38 | t.Errorf("Expected header to be \n%v\n but got \n%v", test.h, h) 39 | } 40 | } 41 | } 42 | 43 | func TestReadHeader_incomplete(t *testing.T) { 44 | r := strings.NewReader("From: \r\nTo") 45 | _, err := readHeader(bufio.NewReader(r)) 46 | if err == nil { 47 | t.Error("Expected an error while reading incomplete header") 48 | } 49 | } 50 | 51 | func TestFormatHeaderParams(t *testing.T) { 52 | params := map[string]string{ 53 | "v": "1", 54 | "a": "rsa-sha256", 55 | "d": "example.org", 56 | } 57 | 58 | expected := "DKIM-Signature: a=rsa-sha256; d=example.org; v=1;" 59 | 60 | s := formatHeaderParams("DKIM-Signature", params) 61 | if s != expected { 62 | t.Errorf("Expected formatted params to be %q, but got %q", expected, s) 63 | } 64 | } 65 | 66 | func TestLongHeaderFolding(t *testing.T) { 67 | // see #29 and #27 68 | 69 | params := map[string]string{ 70 | "v": "1", 71 | "a": "rsa-sha256", 72 | "d": "example.org", 73 | "h": "From:To:Subject:Date:Message-ID:Long-Header-Name", 74 | } 75 | 76 | expected := "DKIM-Signature: a=rsa-sha256; d=example.org;\r\n h=From:To:Subject:Date:Message-ID:Long-Header-Name; v=1;" 77 | 78 | s := formatHeaderParams("DKIM-Signature", params) 79 | if s != expected { 80 | t.Errorf("Expected formatted params to be\n\n %q\n\n, but got\n\n %q", expected, s) 81 | } 82 | } 83 | 84 | func TestSignedHeaderFolding(t *testing.T) { 85 | hValue := "From:To:Subject:Date:Message-ID:Long-Header-Name:Another-Long-Header-Name" 86 | 87 | params := map[string]string{ 88 | "v": "1", 89 | "a": "rsa-sha256", 90 | "d": "example.org", 91 | "h": hValue, 92 | } 93 | 94 | s := formatHeaderParams("DKIM-Signature", params) 95 | if !strings.Contains(s, hValue) { 96 | t.Errorf("Signed Headers names (%v) are not well folded in the signed header %q", hValue, s) 97 | } 98 | } 99 | 100 | func TestParseHeaderParams_malformed(t *testing.T) { 101 | _, err := parseHeaderParams("abc; def") 102 | if err == nil { 103 | t.Error("Expected an error when parsing malformed header params") 104 | } 105 | } 106 | 107 | func TestHeaderPicker_Pick(t *testing.T) { 108 | t.Run("simple", func(t *testing.T) { 109 | predefinedHeaders := []string{"From", "to"} 110 | headers := header{ 111 | "from: fst", 112 | "To: snd", 113 | } 114 | picker := newHeaderPicker(headers) 115 | for i, k := range predefinedHeaders { 116 | if headers[i] != picker.Pick(k) { 117 | t.Errorf("Parameter %s not found in headers %s", k, headers) 118 | } 119 | } 120 | }) 121 | t.Run("a few same headers", func(t *testing.T) { 122 | predefinedHeaders := []string{"to", "to", "to"} 123 | // same headers must returns from Bottom to Top 124 | headers := header{ 125 | "To: trd", 126 | "To: snd", 127 | "To: fst", 128 | } 129 | var lh = len(headers) - 1 130 | picker := newHeaderPicker(headers) 131 | for i, k := range predefinedHeaders { 132 | if headers[lh-i] != picker.Pick(k) { 133 | t.Errorf("Parameter %s not found in headers %s", k, headers) 134 | } 135 | } 136 | }) 137 | t.Run("non-canonical header fields", func(t *testing.T) { 138 | headers := header{ 139 | "Message-ID: asdf", 140 | } 141 | picker := newHeaderPicker(headers) 142 | if v := picker.Pick("Message-Id"); v != headers[0] { 143 | t.Errorf("Pick() = %q, want %q", v, headers[0]) 144 | } 145 | if v := picker.Pick("Message-ID"); v != "" { 146 | t.Errorf("Pick() = %q, want %q", v, "") 147 | } 148 | }) 149 | } 150 | 151 | func TestFoldHeaderField(t *testing.T) { 152 | // fake header with `len(header) % 75 == 74`. See #23 153 | header := `Minimum length header that generates the issue should be of 74 characters ` 154 | expected := "Minimum length header that generates the issue should be of 74 characters \r\n" 155 | folded := foldHeaderField(header) 156 | if folded != expected { 157 | t.Errorf("Extra black line added in header:\n Actual:\n ---Start--- %v ---End---\nExpected: \n ---Start--- %v ---End---\n", folded, expected) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /dkim/canonical_test.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | var simpleCanonicalizerBodyTests = []struct { 9 | original []string 10 | canonical string 11 | }{ 12 | { 13 | []string{""}, 14 | "\r\n", 15 | }, 16 | { 17 | []string{"\r\n"}, 18 | "\r\n", 19 | }, 20 | { 21 | []string{"\r\n\r\n\r\n"}, 22 | "\r\n", 23 | }, 24 | { 25 | []string{"Hey\r\n\r\n"}, 26 | "Hey\r\n", 27 | }, 28 | { 29 | []string{"Hey\r\nHow r u?\r\n\r\n\r\n"}, 30 | "Hey\r\nHow r u?\r\n", 31 | }, 32 | { 33 | []string{"Hey\r\n\r\nHow r u?"}, 34 | "Hey\r\n\r\nHow r u?\r\n", 35 | }, 36 | { 37 | []string{"What about\nLF endings?\n\n"}, 38 | "What about\r\nLF endings?\r\n", 39 | }, 40 | { 41 | []string{"\r\n", "\r", "\n"}, 42 | "\r\n", 43 | }, 44 | { 45 | []string{"\r\n", "\r"}, 46 | "\r\n\r\r\n", 47 | }, 48 | { 49 | []string{"\r\n", "\r", "\n", "hey\n", "\n"}, 50 | "\r\n\r\nhey\r\n", 51 | }, 52 | } 53 | 54 | func TestSimpleCanonicalizer_CanonicalBody(t *testing.T) { 55 | c := new(simpleCanonicalizer) 56 | 57 | var b bytes.Buffer 58 | for _, test := range simpleCanonicalizerBodyTests { 59 | b.Reset() 60 | 61 | wc := c.CanonicalizeBody(&b) 62 | for _, chunk := range test.original { 63 | if _, err := wc.Write([]byte(chunk)); err != nil { 64 | t.Fatalf("Expected no error while writing to simple body canonicalizer, got: %v", err) 65 | } 66 | } 67 | 68 | if err := wc.Close(); err != nil { 69 | t.Errorf("Expected no error while closing simple body canonicalizer, got: %v", err) 70 | } else if s := b.String(); s != test.canonical { 71 | t.Errorf("Expected canonical body for %q to be %q, but got %q", test.original, test.canonical, s) 72 | } 73 | } 74 | } 75 | 76 | var relaxedCanonicalizerHeaderTests = []struct { 77 | original string 78 | canonical string 79 | }{ 80 | { 81 | "SubjeCT: Your Name\r\n", 82 | "subject:Your Name\r\n", 83 | }, 84 | { 85 | "Subject \t:\t Your Name\t \r\n", 86 | "subject:Your Name\r\n", 87 | }, 88 | { 89 | "Subject \t:\t Kimi \t \r\n No \t\r\n Na Wa\r\n", 90 | "subject:Kimi No Na Wa\r\n", 91 | }, 92 | { 93 | "Subject \t:\t Ki \tmi \t \r\n No \t\r\n Na Wa\r\n", 94 | "subject:Ki mi No Na Wa\r\n", 95 | }, 96 | } 97 | 98 | func TestRelaxedCanonicalizer_CanonicalizeHeader(t *testing.T) { 99 | c := new(relaxedCanonicalizer) 100 | 101 | for _, test := range relaxedCanonicalizerHeaderTests { 102 | if s := c.CanonicalizeHeader(test.original); s != test.canonical { 103 | t.Errorf("Expected relaxed canonical header to be %q but got %q", test.canonical, s) 104 | } 105 | } 106 | } 107 | 108 | var relaxedCanonicalizerBodyTests = []struct { 109 | original string 110 | canonical string 111 | }{ 112 | { 113 | "", 114 | "", 115 | }, 116 | { 117 | "\r\n", 118 | "", 119 | }, 120 | { 121 | "\r\n\r\n\r\n", 122 | "", 123 | }, 124 | { 125 | "Hey\r\n\r\n", 126 | "Hey\r\n", 127 | }, 128 | { 129 | "Hey\r\nHow r u?\r\n\r\n\r\n", 130 | "Hey\r\nHow r u?\r\n", 131 | }, 132 | { 133 | "Hey\r\n\r\nHow r u?", 134 | "Hey\r\n\r\nHow r u?\r\n", 135 | }, 136 | { 137 | "Hey \t you!", 138 | "Hey you!\r\n", 139 | }, 140 | { 141 | "Hey \t \r\nyou!", 142 | "Hey\r\nyou!\r\n", 143 | }, 144 | { 145 | "Hey\r\n \t you!\r\n", 146 | "Hey\r\n you!\r\n", 147 | }, 148 | { 149 | "Hey\r\n \t \r\n \r\n", 150 | "Hey\r\n", 151 | }, 152 | } 153 | 154 | func TestRelaxedCanonicalizer_CanonicalBody(t *testing.T) { 155 | c := new(relaxedCanonicalizer) 156 | 157 | var b bytes.Buffer 158 | for _, test := range relaxedCanonicalizerBodyTests { 159 | b.Reset() 160 | 161 | wc := c.CanonicalizeBody(&b) 162 | if _, err := wc.Write([]byte(test.original)); err != nil { 163 | t.Errorf("Expected no error while writing to relaxed body canonicalizer, got: %v", err) 164 | } else if err := wc.Close(); err != nil { 165 | t.Errorf("Expected no error while closing relaxed body canonicalizer, got: %v", err) 166 | } else if s := b.String(); s != test.canonical { 167 | t.Errorf("Expected canonical body for %q to be %q, but got %q", test.original, test.canonical, s) 168 | } 169 | } 170 | } 171 | 172 | func TestRelaxedCanonicalizer_CanonicalBody_splitCRLF(t *testing.T) { 173 | want := "line 1\r\nline 2\r\n" 174 | writes := [][]byte{ 175 | []byte("line 1\r"), 176 | []byte("\nline 2"), 177 | } 178 | 179 | var b bytes.Buffer 180 | wc := new(relaxedCanonicalizer).CanonicalizeBody(&b) 181 | for _, b := range writes { 182 | if _, err := wc.Write(b); err != nil { 183 | t.Errorf("Expected no error while writing to relaxed body canonicalizer, got: %v", err) 184 | } 185 | } 186 | if err := wc.Close(); err != nil { 187 | t.Errorf("Expected no error while closing relaxed body canonicalizer, got: %v", err) 188 | } else if s := b.String(); s != want { 189 | t.Errorf("Expected canonical body to be %q, but got %q", want, s) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /dkim/canonical.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | ) 7 | 8 | // Canonicalization is a canonicalization algorithm. 9 | type Canonicalization string 10 | 11 | const ( 12 | CanonicalizationSimple Canonicalization = "simple" 13 | CanonicalizationRelaxed = "relaxed" 14 | ) 15 | 16 | type canonicalizer interface { 17 | CanonicalizeHeader(s string) string 18 | CanonicalizeBody(w io.Writer) io.WriteCloser 19 | } 20 | 21 | var canonicalizers = map[Canonicalization]canonicalizer{ 22 | CanonicalizationSimple: new(simpleCanonicalizer), 23 | CanonicalizationRelaxed: new(relaxedCanonicalizer), 24 | } 25 | 26 | // crlfFixer fixes any lone LF without a preceding CR. 27 | type crlfFixer struct { 28 | cr bool 29 | } 30 | 31 | func (cf *crlfFixer) Fix(b []byte) []byte { 32 | res := make([]byte, 0, len(b)) 33 | for _, ch := range b { 34 | prevCR := cf.cr 35 | cf.cr = false 36 | switch ch { 37 | case '\r': 38 | cf.cr = true 39 | case '\n': 40 | if !prevCR { 41 | res = append(res, '\r') 42 | } 43 | } 44 | res = append(res, ch) 45 | } 46 | return res 47 | } 48 | 49 | type simpleCanonicalizer struct{} 50 | 51 | func (c *simpleCanonicalizer) CanonicalizeHeader(s string) string { 52 | return s 53 | } 54 | 55 | type simpleBodyCanonicalizer struct { 56 | w io.Writer 57 | crlfBuf []byte 58 | crlfFixer crlfFixer 59 | } 60 | 61 | func (c *simpleBodyCanonicalizer) Write(b []byte) (int, error) { 62 | written := len(b) 63 | b = append(c.crlfBuf, b...) 64 | 65 | b = c.crlfFixer.Fix(b) 66 | 67 | end := len(b) 68 | // If it ends with \r, maybe the next write will begin with \n 69 | if end > 0 && b[end-1] == '\r' { 70 | end-- 71 | } 72 | // Keep all \r\n sequences 73 | for end >= 2 { 74 | prev := b[end-2] 75 | cur := b[end-1] 76 | if prev != '\r' || cur != '\n' { 77 | break 78 | } 79 | end -= 2 80 | } 81 | 82 | c.crlfBuf = b[end:] 83 | 84 | var err error 85 | if end > 0 { 86 | _, err = c.w.Write(b[:end]) 87 | } 88 | return written, err 89 | } 90 | 91 | func (c *simpleBodyCanonicalizer) Close() error { 92 | // Flush crlfBuf if it ends with a single \r (without a matching \n) 93 | if len(c.crlfBuf) > 0 && c.crlfBuf[len(c.crlfBuf)-1] == '\r' { 94 | if _, err := c.w.Write(c.crlfBuf); err != nil { 95 | return err 96 | } 97 | } 98 | c.crlfBuf = nil 99 | 100 | if _, err := c.w.Write([]byte(crlf)); err != nil { 101 | return err 102 | } 103 | return nil 104 | } 105 | 106 | func (c *simpleCanonicalizer) CanonicalizeBody(w io.Writer) io.WriteCloser { 107 | return &simpleBodyCanonicalizer{w: w} 108 | } 109 | 110 | type relaxedCanonicalizer struct{} 111 | 112 | func (c *relaxedCanonicalizer) CanonicalizeHeader(s string) string { 113 | k, v, ok := strings.Cut(s, ":") 114 | if !ok { 115 | return strings.TrimSpace(strings.ToLower(s)) + ":" + crlf 116 | } 117 | 118 | k = strings.TrimSpace(strings.ToLower(k)) 119 | v = strings.Join(strings.FieldsFunc(v, func(r rune) bool { 120 | return r == ' ' || r == '\t' || r == '\n' || r == '\r' 121 | }), " ") 122 | return k + ":" + v + crlf 123 | } 124 | 125 | type relaxedBodyCanonicalizer struct { 126 | w io.Writer 127 | crlfBuf []byte 128 | wsp bool 129 | written bool 130 | crlfFixer crlfFixer 131 | } 132 | 133 | func (c *relaxedBodyCanonicalizer) Write(b []byte) (int, error) { 134 | written := len(b) 135 | 136 | b = c.crlfFixer.Fix(b) 137 | 138 | canonical := make([]byte, 0, len(b)) 139 | for _, ch := range b { 140 | if ch == ' ' || ch == '\t' { 141 | c.wsp = true 142 | } else if ch == '\r' || ch == '\n' { 143 | c.wsp = false 144 | c.crlfBuf = append(c.crlfBuf, ch) 145 | } else { 146 | if len(c.crlfBuf) > 0 { 147 | canonical = append(canonical, c.crlfBuf...) 148 | c.crlfBuf = c.crlfBuf[:0] 149 | } 150 | if c.wsp { 151 | canonical = append(canonical, ' ') 152 | c.wsp = false 153 | } 154 | 155 | canonical = append(canonical, ch) 156 | } 157 | } 158 | 159 | if !c.written && len(canonical) > 0 { 160 | c.written = true 161 | } 162 | 163 | _, err := c.w.Write(canonical) 164 | return written, err 165 | } 166 | 167 | func (c *relaxedBodyCanonicalizer) Close() error { 168 | if c.written { 169 | if _, err := c.w.Write([]byte(crlf)); err != nil { 170 | return err 171 | } 172 | } 173 | return nil 174 | } 175 | 176 | func (c *relaxedCanonicalizer) CanonicalizeBody(w io.Writer) io.WriteCloser { 177 | return &relaxedBodyCanonicalizer{w: w} 178 | } 179 | 180 | type limitedWriter struct { 181 | W io.Writer 182 | N int64 183 | } 184 | 185 | func (w *limitedWriter) Write(b []byte) (int, error) { 186 | if w.N <= 0 { 187 | return len(b), nil 188 | } 189 | 190 | skipped := 0 191 | if int64(len(b)) > w.N { 192 | b = b[:w.N] 193 | skipped = int(int64(len(b)) - w.N) 194 | } 195 | 196 | n, err := w.W.Write(b) 197 | w.N -= int64(n) 198 | return n + skipped, err 199 | } 200 | -------------------------------------------------------------------------------- /dkim/sign_test.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "math/rand" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | const mailHeaderString = "From: Joe SixPack \r\n" + 12 | "To: Suzie Q \r\n" + 13 | "Subject: Is dinner ready?\r\n" + 14 | "Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)\r\n" + 15 | "Message-ID: <20030712040037.46341.5F8J@football.example.com>\r\n" 16 | 17 | const mailBodyString = "Hi.\r\n" + 18 | "\r\n" + 19 | "We lost the game. Are you hungry yet?\r\n" + 20 | "\r\n" + 21 | "Joe." 22 | 23 | const mailString = mailHeaderString + "\r\n" + mailBodyString 24 | 25 | const signedMailString = "DKIM-Signature: a=rsa-sha256; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;" + "\r\n" + 26 | " " + "c=simple/simple; d=example.org; h=From:To:Subject:Date:Message-ID;" + "\r\n" + 27 | " " + "s=brisbane; t=424242; v=1;" + "\r\n" + 28 | " " + "b=MobyyDTeHhMhNJCEI6ATNK63ZQ7deSXK9umyzAvYwFqE6oGGvlQBQwqr1aC11hWpktjMLP1/" + "\r\n" + 29 | " " + "m0PBi9v7cRLKMXXBIv2O0B1mIWdZPqd9jveRJqKzCb7SpqH2u5kK6i2vZI639ENTQzRQdxSAGXc" + "\r\n" + 30 | " " + "PcPYjrgkqj7xklnrNBs0aIUA=" + "\r\n" + 31 | mailHeaderString + 32 | "\r\n" + 33 | mailBodyString 34 | 35 | func init() { 36 | randReader = rand.New(rand.NewSource(42)) 37 | } 38 | 39 | func TestSign(t *testing.T) { 40 | r := strings.NewReader(mailString) 41 | options := &SignOptions{ 42 | Domain: "example.org", 43 | Selector: "brisbane", 44 | Signer: testPrivateKey, 45 | } 46 | 47 | var b bytes.Buffer 48 | if err := Sign(&b, r, options); err != nil { 49 | t.Fatal("Expected no error while signing mail, got:", err) 50 | } 51 | 52 | if s := b.String(); s != signedMailString { 53 | t.Errorf("Expected signed message to be \n%v\n but got \n%v", signedMailString, s) 54 | } 55 | } 56 | 57 | func TestSignAndVerify(t *testing.T) { 58 | r := strings.NewReader(mailString) 59 | options := &SignOptions{ 60 | Domain: "example.org", 61 | Selector: "brisbane", 62 | Signer: testPrivateKey, 63 | } 64 | 65 | var b bytes.Buffer 66 | if err := Sign(&b, r, options); err != nil { 67 | t.Fatal("Expected no error while signing mail, got:", err) 68 | } 69 | 70 | verifications, err := Verify(&b) 71 | if err != nil { 72 | t.Fatalf("Expected no error while verifying signature, got: %v", err) 73 | } 74 | if len(verifications) != 1 { 75 | t.Error("Expected exactly one verification") 76 | } else { 77 | v := verifications[0] 78 | if err := v.Err; err != nil { 79 | t.Errorf("Expected no error when verifying signature, got: %v", err) 80 | } 81 | if v.Domain != options.Domain { 82 | t.Errorf("Expected domain to be %q but got %q", options.Domain, v.Domain) 83 | } 84 | } 85 | } 86 | 87 | func TestSignAndVerify_relaxed(t *testing.T) { 88 | r := strings.NewReader(mailString) 89 | options := &SignOptions{ 90 | Domain: "example.org", 91 | Selector: "brisbane", 92 | Signer: testPrivateKey, 93 | HeaderCanonicalization: "relaxed", 94 | BodyCanonicalization: "relaxed", 95 | } 96 | 97 | var b bytes.Buffer 98 | if err := Sign(&b, r, options); err != nil { 99 | t.Fatal("Expected no error while signing mail, got:", err) 100 | } 101 | 102 | verifications, err := Verify(&b) 103 | if err != nil { 104 | t.Fatalf("Expected no error while verifying signature, got: %v", err) 105 | } 106 | if len(verifications) != 1 { 107 | t.Error("Expected exactly one verification") 108 | } 109 | } 110 | 111 | func TestSign_invalidOptions(t *testing.T) { 112 | r := strings.NewReader(mailString) 113 | var b bytes.Buffer 114 | 115 | if err := Sign(&b, r, nil); err == nil { 116 | t.Error("Expected an error when signing a message without options") 117 | } 118 | 119 | options := &SignOptions{} 120 | if err := Sign(&b, r, options); err == nil { 121 | t.Error("Expected an error when signing a message without domain") 122 | } 123 | options.Domain = "example.org" 124 | 125 | if err := Sign(&b, r, options); err == nil { 126 | t.Error("Expected an error when signing a message without selector") 127 | } 128 | options.Selector = "brisbane" 129 | 130 | if err := Sign(&b, r, options); err == nil { 131 | t.Error("Expected an error when signing a message without signer") 132 | } 133 | options.Signer = testPrivateKey 134 | 135 | options.HeaderCanonicalization = "pasta" 136 | if err := Sign(&b, r, options); err == nil { 137 | t.Error("Expected an error when signing a message with an invalid header canonicalization") 138 | } 139 | options.HeaderCanonicalization = "" 140 | 141 | options.BodyCanonicalization = "potatoe" 142 | if err := Sign(&b, r, options); err == nil { 143 | t.Error("Expected an error when signing a message with an invalid body canonicalization") 144 | } 145 | options.BodyCanonicalization = "" 146 | 147 | options.BodyCanonicalization = "potatoe" 148 | if err := Sign(&b, r, options); err == nil { 149 | t.Error("Expected an error when signing a message with an invalid body canonicalization") 150 | } 151 | options.BodyCanonicalization = "" 152 | 153 | options.Hash = ^crypto.Hash(0) 154 | if err := Sign(&b, r, options); err == nil { 155 | t.Error("Expected an error when signing a message with an invalid hash algorithm") 156 | } 157 | options.Hash = 0 158 | 159 | options.HeaderKeys = []string{"To"} 160 | if err := Sign(&b, r, options); err == nil { 161 | t.Error("Expected an error when signing a message without the From header") 162 | } 163 | options.HeaderKeys = nil 164 | } 165 | -------------------------------------------------------------------------------- /dkim/query.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "crypto" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/base64" 8 | "errors" 9 | "fmt" 10 | "net" 11 | "strings" 12 | 13 | "golang.org/x/crypto/ed25519" 14 | ) 15 | 16 | type verifier interface { 17 | Public() crypto.PublicKey 18 | Verify(hash crypto.Hash, hashed []byte, sig []byte) error 19 | } 20 | 21 | type rsaVerifier struct { 22 | *rsa.PublicKey 23 | } 24 | 25 | func (v rsaVerifier) Public() crypto.PublicKey { 26 | return v.PublicKey 27 | } 28 | 29 | func (v rsaVerifier) Verify(hash crypto.Hash, hashed, sig []byte) error { 30 | return rsa.VerifyPKCS1v15(v.PublicKey, hash, hashed, sig) 31 | } 32 | 33 | type ed25519Verifier struct { 34 | ed25519.PublicKey 35 | } 36 | 37 | func (v ed25519Verifier) Public() crypto.PublicKey { 38 | return v.PublicKey 39 | } 40 | 41 | func (v ed25519Verifier) Verify(hash crypto.Hash, hashed, sig []byte) error { 42 | if !ed25519.Verify(v.PublicKey, hashed, sig) { 43 | return errors.New("dkim: invalid Ed25519 signature") 44 | } 45 | return nil 46 | } 47 | 48 | type queryResult struct { 49 | Verifier verifier 50 | KeyAlgo string 51 | HashAlgos []string 52 | Notes string 53 | Services []string 54 | Flags []string 55 | } 56 | 57 | // QueryMethod is a DKIM query method. 58 | type QueryMethod string 59 | 60 | const ( 61 | // DNS TXT resource record (RR) lookup algorithm 62 | QueryMethodDNSTXT QueryMethod = "dns/txt" 63 | ) 64 | 65 | type txtLookupFunc func(domain string) ([]string, error) 66 | type queryFunc func(domain, selector string, txtLookup txtLookupFunc) (*queryResult, error) 67 | 68 | var queryMethods = map[QueryMethod]queryFunc{ 69 | QueryMethodDNSTXT: queryDNSTXT, 70 | } 71 | 72 | func queryDNSTXT(domain, selector string, txtLookup txtLookupFunc) (*queryResult, error) { 73 | if txtLookup == nil { 74 | txtLookup = net.LookupTXT 75 | } 76 | 77 | txts, err := txtLookup(selector + "._domainkey." + domain) 78 | if netErr, ok := err.(net.Error); ok && netErr.Temporary() { 79 | return nil, tempFailError("key unavailable: " + err.Error()) 80 | } else if err != nil { 81 | return nil, permFailError("no key for signature: " + err.Error()) 82 | } 83 | 84 | // net.LookupTXT will concatenate strings contained in a single TXT record. 85 | // In other words, net.LookupTXT returns one entry per TXT record, even if 86 | // a record contains multiple strings. 87 | // 88 | // RFC 6376 section 3.6.2.2 says multiple TXT records lead to undefined 89 | // behavior, so reject that. 90 | switch len(txts) { 91 | case 0: 92 | return nil, permFailError("no valid key found") 93 | case 1: 94 | return parsePublicKey(txts[0]) 95 | default: 96 | return nil, permFailError("multiple TXT records found for key") 97 | } 98 | } 99 | 100 | func parsePublicKey(s string) (*queryResult, error) { 101 | params, err := parseHeaderParams(s) 102 | if err != nil { 103 | return nil, permFailError("key record error: " + err.Error()) 104 | } 105 | 106 | res := new(queryResult) 107 | 108 | if v, ok := params["v"]; ok && v != "DKIM1" { 109 | return nil, permFailError("incompatible public key version") 110 | } 111 | 112 | p, ok := params["p"] 113 | if !ok { 114 | return nil, permFailError("key syntax error: missing public key data") 115 | } 116 | if p == "" { 117 | return nil, permFailError("key revoked") 118 | } 119 | p = strings.ReplaceAll(p, " ", "") 120 | b, err := base64.StdEncoding.DecodeString(p) 121 | if err != nil { 122 | return nil, permFailError("key syntax error: " + err.Error()) 123 | } 124 | switch params["k"] { 125 | case "rsa", "": 126 | pub, err := x509.ParsePKIXPublicKey(b) 127 | if err != nil { 128 | // RFC 6376 is inconsistent about whether RSA public keys should 129 | // be formatted as RSAPublicKey or SubjectPublicKeyInfo. 130 | // Erratum 3017 (https://www.rfc-editor.org/errata/eid3017) proposes 131 | // allowing both. 132 | pub, err = x509.ParsePKCS1PublicKey(b) 133 | if err != nil { 134 | return nil, permFailError("key syntax error: " + err.Error()) 135 | } 136 | } 137 | rsaPub, ok := pub.(*rsa.PublicKey) 138 | if !ok { 139 | return nil, permFailError("key syntax error: not an RSA public key") 140 | } 141 | // RFC 8301 section 3.2: verifiers MUST NOT consider signatures using 142 | // RSA keys of less than 1024 bits as valid signatures. 143 | if rsaPub.Size()*8 < 1024 { 144 | return nil, permFailError(fmt.Sprintf("key is too short: want 1024 bits, has %v bits", rsaPub.Size()*8)) 145 | } 146 | res.Verifier = rsaVerifier{rsaPub} 147 | res.KeyAlgo = "rsa" 148 | case "ed25519": 149 | if len(b) != ed25519.PublicKeySize { 150 | return nil, permFailError(fmt.Sprintf("invalid Ed25519 public key size: %v bytes", len(b))) 151 | } 152 | ed25519Pub := ed25519.PublicKey(b) 153 | res.Verifier = ed25519Verifier{ed25519Pub} 154 | res.KeyAlgo = "ed25519" 155 | default: 156 | return nil, permFailError("unsupported key algorithm") 157 | } 158 | 159 | if hashesStr, ok := params["h"]; ok { 160 | res.HashAlgos = parseTagList(hashesStr) 161 | } 162 | if notes, ok := params["n"]; ok { 163 | res.Notes = notes 164 | } 165 | if servicesStr, ok := params["s"]; ok { 166 | services := parseTagList(servicesStr) 167 | 168 | hasWildcard := false 169 | for _, s := range services { 170 | if s == "*" { 171 | hasWildcard = true 172 | break 173 | } 174 | } 175 | if !hasWildcard { 176 | res.Services = services 177 | } 178 | } 179 | if flagsStr, ok := params["t"]; ok { 180 | res.Flags = parseTagList(flagsStr) 181 | } 182 | 183 | return res, nil 184 | } 185 | -------------------------------------------------------------------------------- /dmarc/lookup.go: -------------------------------------------------------------------------------- 1 | package dmarc 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type tempFailError string 13 | 14 | func (err tempFailError) Error() string { 15 | return "dmarc: " + string(err) 16 | } 17 | 18 | // IsTempFail returns true if the error returned by Lookup is a temporary 19 | // failure. 20 | func IsTempFail(err error) bool { 21 | _, ok := err.(tempFailError) 22 | return ok 23 | } 24 | 25 | var ErrNoPolicy = errors.New("dmarc: no policy found for domain") 26 | 27 | var errUnsupportedVersion = errors.New("dmarc: unsupported DMARC version") 28 | 29 | // LookupOptions allows to customize the default signature verification behavior 30 | // LookupTXT returns the DNS TXT records for the given domain name. If nil, net.LookupTXT is used 31 | type LookupOptions struct { 32 | LookupTXT func(domain string) ([]string, error) 33 | } 34 | 35 | // Lookup queries a DMARC record for a specified domain. 36 | func Lookup(domain string) (*Record, error) { 37 | return LookupWithOptions(domain, nil) 38 | } 39 | 40 | func LookupWithOptions(domain string, options *LookupOptions) (*Record, error) { 41 | var txts []string 42 | var err error 43 | if options != nil && options.LookupTXT != nil { 44 | txts, err = options.LookupTXT("_dmarc." + domain) 45 | } else { 46 | txts, err = net.LookupTXT("_dmarc." + domain) 47 | } 48 | if netErr, ok := err.(net.Error); ok && netErr.Temporary() { 49 | return nil, tempFailError("TXT record unavailable: " + err.Error()) 50 | } else if err != nil { 51 | if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { 52 | return nil, ErrNoPolicy 53 | } 54 | return nil, errors.New("dmarc: failed to lookup TXT record: " + err.Error()) 55 | } 56 | 57 | for _, txt := range txts { 58 | if !strings.HasPrefix(txt, "v=") { 59 | continue 60 | } 61 | record, err := Parse(txt) 62 | if err == errUnsupportedVersion { 63 | continue 64 | } 65 | return record, err 66 | } 67 | 68 | return nil, ErrNoPolicy 69 | } 70 | 71 | func Parse(txt string) (*Record, error) { 72 | params, err := parseParams(txt) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | if params["v"] != "DMARC1" { 78 | return nil, errUnsupportedVersion 79 | } 80 | 81 | rec := new(Record) 82 | 83 | p, ok := params["p"] 84 | if !ok { 85 | return nil, errors.New("dmarc: record is missing a 'p' parameter") 86 | } 87 | rec.Policy, err = parsePolicy(p, "p") 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | rec.DKIMAlignment = AlignmentRelaxed 93 | if adkim, ok := params["adkim"]; ok { 94 | rec.DKIMAlignment, err = parseAlignmentMode(adkim, "adkim") 95 | if err != nil { 96 | return nil, err 97 | } 98 | } 99 | 100 | rec.SPFAlignment = AlignmentRelaxed 101 | if aspf, ok := params["aspf"]; ok { 102 | rec.SPFAlignment, err = parseAlignmentMode(aspf, "aspf") 103 | if err != nil { 104 | return nil, err 105 | } 106 | } 107 | 108 | if fo, ok := params["fo"]; ok { 109 | rec.FailureOptions, err = parseFailureOptions(fo) 110 | if err != nil { 111 | return nil, err 112 | } 113 | } 114 | 115 | if pct, ok := params["pct"]; ok { 116 | i, err := strconv.Atoi(pct) 117 | if err != nil { 118 | return nil, fmt.Errorf("dmarc: invalid parameter 'pct': %v", err) 119 | } 120 | if i < 0 || i > 100 { 121 | return nil, fmt.Errorf("dmarc: invalid parameter 'pct': value %v out of bounds", i) 122 | } 123 | rec.Percent = &i 124 | } 125 | 126 | if rf, ok := params["rf"]; ok { 127 | l := strings.Split(rf, ":") 128 | rec.ReportFormat = make([]ReportFormat, len(l)) 129 | for i, f := range l { 130 | switch f { 131 | case "afrf": 132 | rec.ReportFormat[i] = ReportFormat(f) 133 | default: 134 | return nil, errors.New("dmarc: invalid parameter 'rf'") 135 | } 136 | } 137 | } 138 | 139 | if ri, ok := params["ri"]; ok { 140 | i, err := strconv.Atoi(ri) 141 | if err != nil { 142 | return nil, fmt.Errorf("dmarc: invalid parameter 'ri': %v", err) 143 | } 144 | if i <= 0 { 145 | return nil, fmt.Errorf("dmarc: invalid parameter 'ri': negative or zero duration") 146 | } 147 | rec.ReportInterval = time.Duration(i) * time.Second 148 | } 149 | 150 | if rua, ok := params["rua"]; ok { 151 | rec.ReportURIAggregate = parseURIList(rua) 152 | } 153 | 154 | if ruf, ok := params["ruf"]; ok { 155 | rec.ReportURIFailure = parseURIList(ruf) 156 | } 157 | 158 | if sp, ok := params["sp"]; ok { 159 | rec.SubdomainPolicy, err = parsePolicy(sp, "sp") 160 | if err != nil { 161 | return nil, err 162 | } 163 | } 164 | 165 | return rec, nil 166 | } 167 | 168 | func parseParams(s string) (map[string]string, error) { 169 | pairs := strings.Split(s, ";") 170 | params := make(map[string]string) 171 | for _, s := range pairs { 172 | kv := strings.SplitN(s, "=", 2) 173 | if len(kv) != 2 { 174 | if strings.TrimSpace(s) == "" { 175 | continue 176 | } 177 | return params, errors.New("dmarc: malformed params") 178 | } 179 | 180 | params[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) 181 | } 182 | return params, nil 183 | } 184 | 185 | func parsePolicy(s, param string) (Policy, error) { 186 | switch s { 187 | case "none", "quarantine", "reject": 188 | return Policy(s), nil 189 | default: 190 | return "", fmt.Errorf("dmarc: invalid policy for parameter '%v'", param) 191 | } 192 | } 193 | 194 | func parseAlignmentMode(s, param string) (AlignmentMode, error) { 195 | switch s { 196 | case "r", "s": 197 | return AlignmentMode(s), nil 198 | default: 199 | return "", fmt.Errorf("dmarc: invalid alignment mode for parameter '%v'", param) 200 | } 201 | } 202 | 203 | func parseFailureOptions(s string) (FailureOptions, error) { 204 | l := strings.Split(s, ":") 205 | var opts FailureOptions 206 | for _, o := range l { 207 | switch strings.TrimSpace(o) { 208 | case "0": 209 | opts |= FailureAll 210 | case "1": 211 | opts |= FailureAny 212 | case "d": 213 | opts |= FailureDKIM 214 | case "s": 215 | opts |= FailureSPF 216 | default: 217 | return 0, errors.New("dmarc: invalid failure option in parameter 'fo'") 218 | } 219 | } 220 | return opts, nil 221 | } 222 | 223 | func parseURIList(s string) []string { 224 | l := strings.Split(s, ",") 225 | for i, u := range l { 226 | l[i] = strings.TrimSpace(u) 227 | } 228 | return l 229 | } 230 | -------------------------------------------------------------------------------- /authres/parse.go: -------------------------------------------------------------------------------- 1 | package authres 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "unicode" 9 | ) 10 | 11 | // ResultValue is an authentication result value, as defined in RFC 5451 section 12 | // 6.3. 13 | type ResultValue string 14 | 15 | const ( 16 | ResultNone ResultValue = "none" 17 | ResultPass = "pass" 18 | ResultFail = "fail" 19 | ResultPolicy = "policy" 20 | ResultNeutral = "neutral" 21 | ResultTempError = "temperror" 22 | ResultPermError = "permerror" 23 | ResultHardFail = "hardfail" 24 | ResultSoftFail = "softfail" 25 | ) 26 | 27 | // Result is an authentication result. 28 | type Result interface { 29 | parse(value ResultValue, params map[string]string) error 30 | format() (value ResultValue, params map[string]string) 31 | } 32 | 33 | type AuthResult struct { 34 | Value ResultValue 35 | Reason string 36 | Auth string 37 | } 38 | 39 | func (r *AuthResult) parse(value ResultValue, params map[string]string) error { 40 | r.Value = value 41 | r.Reason = params["reason"] 42 | r.Auth = params["smtp.auth"] 43 | return nil 44 | } 45 | 46 | func (r *AuthResult) format() (ResultValue, map[string]string) { 47 | return r.Value, map[string]string{"smtp.auth": r.Auth} 48 | } 49 | 50 | type DKIMResult struct { 51 | Value ResultValue 52 | Reason string 53 | Domain string 54 | Identifier string 55 | } 56 | 57 | func (r *DKIMResult) parse(value ResultValue, params map[string]string) error { 58 | r.Value = value 59 | r.Reason = params["reason"] 60 | r.Domain = params["header.d"] 61 | r.Identifier = params["header.i"] 62 | return nil 63 | } 64 | 65 | func (r *DKIMResult) format() (ResultValue, map[string]string) { 66 | return r.Value, map[string]string{ 67 | "reason": r.Reason, 68 | "header.d": r.Domain, 69 | "header.i": r.Identifier, 70 | } 71 | } 72 | 73 | type DomainKeysResult struct { 74 | Value ResultValue 75 | Reason string 76 | Domain string 77 | From string 78 | Sender string 79 | } 80 | 81 | func (r *DomainKeysResult) parse(value ResultValue, params map[string]string) error { 82 | r.Value = value 83 | r.Reason = params["reason"] 84 | r.Domain = params["header.d"] 85 | r.From = params["header.from"] 86 | r.Sender = params["header.sender"] 87 | return nil 88 | } 89 | 90 | func (r *DomainKeysResult) format() (ResultValue, map[string]string) { 91 | return r.Value, map[string]string{ 92 | "reason": r.Reason, 93 | "header.d": r.Domain, 94 | "header.from": r.From, 95 | "header.sender": r.Sender, 96 | } 97 | } 98 | 99 | type IPRevResult struct { 100 | Value ResultValue 101 | Reason string 102 | IP string 103 | } 104 | 105 | func (r *IPRevResult) parse(value ResultValue, params map[string]string) error { 106 | r.Value = value 107 | r.Reason = params["reason"] 108 | r.IP = params["policy.iprev"] 109 | return nil 110 | } 111 | 112 | func (r *IPRevResult) format() (ResultValue, map[string]string) { 113 | return r.Value, map[string]string{ 114 | "reason": r.Reason, 115 | "policy.iprev": r.IP, 116 | } 117 | } 118 | 119 | type SenderIDResult struct { 120 | Value ResultValue 121 | Reason string 122 | HeaderKey string 123 | HeaderValue string 124 | } 125 | 126 | func (r *SenderIDResult) parse(value ResultValue, params map[string]string) error { 127 | r.Value = value 128 | r.Reason = params["reason"] 129 | 130 | for k, v := range params { 131 | if strings.HasPrefix(k, "header.") { 132 | r.HeaderKey = strings.TrimPrefix(k, "header.") 133 | r.HeaderValue = v 134 | break 135 | } 136 | } 137 | 138 | return nil 139 | } 140 | 141 | func (r *SenderIDResult) format() (value ResultValue, params map[string]string) { 142 | return r.Value, map[string]string{ 143 | "reason": r.Reason, 144 | "header." + strings.ToLower(r.HeaderKey): r.HeaderValue, 145 | } 146 | } 147 | 148 | type SPFResult struct { 149 | Value ResultValue 150 | Reason string 151 | From string 152 | Helo string 153 | } 154 | 155 | func (r *SPFResult) parse(value ResultValue, params map[string]string) error { 156 | r.Value = value 157 | r.Reason = params["reason"] 158 | r.From = params["smtp.mailfrom"] 159 | r.Helo = params["smtp.helo"] 160 | return nil 161 | } 162 | 163 | func (r *SPFResult) format() (ResultValue, map[string]string) { 164 | return r.Value, map[string]string{ 165 | "reason": r.Reason, 166 | "smtp.mailfrom": r.From, 167 | "smtp.helo": r.Helo, 168 | } 169 | } 170 | 171 | type DMARCResult struct { 172 | Value ResultValue 173 | Reason string 174 | From string 175 | } 176 | 177 | func (r *DMARCResult) parse(value ResultValue, params map[string]string) error { 178 | r.Value = value 179 | r.Reason = params["reason"] 180 | r.From = params["header.from"] 181 | return nil 182 | } 183 | 184 | func (r *DMARCResult) format() (ResultValue, map[string]string) { 185 | return r.Value, map[string]string{ 186 | "reason": r.Reason, 187 | "header.from": r.From, 188 | } 189 | } 190 | 191 | type ARCResult struct { 192 | Value ResultValue 193 | RemoteIP string 194 | OldestPass int 195 | } 196 | 197 | func (r *ARCResult) parse(value ResultValue, params map[string]string) error { 198 | var oldestPass int 199 | if s, ok := params["header.oldest-pass"]; ok { 200 | var err error 201 | oldestPass, err = strconv.Atoi(s) 202 | if err != nil { 203 | return fmt.Errorf("invalid header.oldest-pass param: %v", err) 204 | } else if oldestPass <= 0 { 205 | return fmt.Errorf("invalid header.oldest-pass param: must be >= 1") 206 | } 207 | } 208 | 209 | r.Value = value 210 | r.RemoteIP = params["smtp.remote-ip"] 211 | r.OldestPass = oldestPass 212 | return nil 213 | } 214 | 215 | func (r *ARCResult) format() (ResultValue, map[string]string) { 216 | var oldestPass string 217 | if r.OldestPass > 0 { 218 | oldestPass = strconv.Itoa(r.OldestPass) 219 | } 220 | 221 | return r.Value, map[string]string{ 222 | "smtp.remote-ip": r.RemoteIP, 223 | "header.oldest-pass": oldestPass, 224 | } 225 | } 226 | 227 | type GenericResult struct { 228 | Method string 229 | Value ResultValue 230 | Params map[string]string 231 | } 232 | 233 | func (r *GenericResult) parse(value ResultValue, params map[string]string) error { 234 | r.Value = value 235 | r.Params = params 236 | return nil 237 | } 238 | 239 | func (r *GenericResult) format() (ResultValue, map[string]string) { 240 | return r.Value, r.Params 241 | } 242 | 243 | type newResultFunc func() Result 244 | 245 | var results = map[string]newResultFunc{ 246 | "arc": func() Result { 247 | return new(ARCResult) 248 | }, 249 | "auth": func() Result { 250 | return new(AuthResult) 251 | }, 252 | "dkim": func() Result { 253 | return new(DKIMResult) 254 | }, 255 | "domainkeys": func() Result { 256 | return new(DomainKeysResult) 257 | }, 258 | "iprev": func() Result { 259 | return new(IPRevResult) 260 | }, 261 | "sender-id": func() Result { 262 | return new(SenderIDResult) 263 | }, 264 | "spf": func() Result { 265 | return new(SPFResult) 266 | }, 267 | "dmarc": func() Result { 268 | return new(DMARCResult) 269 | }, 270 | } 271 | 272 | // Parse parses the provided Authentication-Results header field. It returns the 273 | // authentication service identifier and authentication results. 274 | func Parse(v string) (identifier string, results []Result, err error) { 275 | parts := strings.Split(v, ";") 276 | 277 | identifier = strings.TrimSpace(parts[0]) 278 | i := strings.IndexFunc(identifier, unicode.IsSpace) 279 | if i > 0 { 280 | version := strings.TrimSpace(identifier[i:]) 281 | if version != "1" { 282 | return "", nil, errors.New("msgauth: unsupported version") 283 | } 284 | 285 | identifier = identifier[:i] 286 | } 287 | 288 | for i := 1; i < len(parts); i++ { 289 | s := strings.TrimSpace(parts[i]) 290 | if s == "" { 291 | continue 292 | } 293 | 294 | result, err := parseResult(s) 295 | if err != nil { 296 | return identifier, results, err 297 | } 298 | if result != nil { 299 | results = append(results, result) 300 | } 301 | } 302 | return 303 | } 304 | 305 | func parseResult(s string) (Result, error) { 306 | // TODO: ignore header comments in parenthesis 307 | 308 | parts := strings.Fields(s) 309 | if len(parts) == 0 || parts[0] == "none" { 310 | return nil, nil 311 | } 312 | 313 | k, v, err := parseParam(parts[0]) 314 | if err != nil { 315 | return nil, err 316 | } 317 | method, value := k, ResultValue(strings.ToLower(v)) 318 | 319 | params := make(map[string]string) 320 | for i := 1; i < len(parts); i++ { 321 | k, v, err := parseParam(parts[i]) 322 | if err != nil { 323 | continue 324 | } 325 | 326 | params[k] = v 327 | } 328 | 329 | newResult, ok := results[method] 330 | 331 | var r Result 332 | if ok { 333 | r = newResult() 334 | } else { 335 | r = &GenericResult{ 336 | Method: method, 337 | Value: value, 338 | Params: params, 339 | } 340 | } 341 | 342 | err = r.parse(value, params) 343 | return r, err 344 | } 345 | 346 | func parseParam(s string) (k string, v string, err error) { 347 | k, v, ok := strings.Cut(s, "=") 348 | if !ok { 349 | return "", "", errors.New("msgauth: malformed authentication method and value") 350 | } 351 | return strings.ToLower(strings.TrimSpace(k)), strings.TrimSpace(v), nil 352 | } 353 | -------------------------------------------------------------------------------- /dkim/sign.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto" 7 | "crypto/rand" 8 | "crypto/rsa" 9 | "encoding/base64" 10 | "fmt" 11 | "io" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "golang.org/x/crypto/ed25519" 17 | ) 18 | 19 | var randReader io.Reader = rand.Reader 20 | 21 | // SignOptions is used to configure Sign. Domain, Selector and Signer are 22 | // mandatory. 23 | type SignOptions struct { 24 | // The SDID claiming responsibility for an introduction of a message into the 25 | // mail stream. Hence, the SDID value is used to form the query for the public 26 | // key. The SDID MUST correspond to a valid DNS name under which the DKIM key 27 | // record is published. 28 | // 29 | // This can't be empty. 30 | Domain string 31 | // The selector subdividing the namespace for the domain. 32 | // 33 | // This can't be empty. 34 | Selector string 35 | // The Agent or User Identifier (AUID) on behalf of which the SDID is taking 36 | // responsibility. 37 | // 38 | // This is optional. 39 | Identifier string 40 | 41 | // The key used to sign the message. 42 | // 43 | // Supported Signer.Public() values are *rsa.PublicKey and 44 | // ed25519.PublicKey. 45 | Signer crypto.Signer 46 | // The hash algorithm used to sign the message. If zero, a default hash will 47 | // be chosen. 48 | // 49 | // The only supported hash algorithm is crypto.SHA256. 50 | Hash crypto.Hash 51 | 52 | // Header and body canonicalization algorithms. 53 | // 54 | // If empty, CanonicalizationSimple is used. 55 | HeaderCanonicalization Canonicalization 56 | BodyCanonicalization Canonicalization 57 | 58 | // A list of header fields to include in the signature. If nil, all headers 59 | // will be included. If not nil, "From" MUST be in the list. 60 | // 61 | // See RFC 6376 section 5.4.1 for recommended header fields. 62 | HeaderKeys []string 63 | 64 | // The expiration time. A zero value means no expiration. 65 | Expiration time.Time 66 | 67 | // A list of query methods used to retrieve the public key. 68 | // 69 | // If nil, it is implicitly defined as QueryMethodDNSTXT. 70 | QueryMethods []QueryMethod 71 | } 72 | 73 | // Signer generates a DKIM signature. 74 | // 75 | // The whole message header and body must be written to the Signer. Close should 76 | // always be called (either after the whole message has been written, or after 77 | // an error occurred and the signer won't be used anymore). Close may return an 78 | // error in case signing fails. 79 | // 80 | // After a successful Close, Signature can be called to retrieve the 81 | // DKIM-Signature header field that the caller should prepend to the message. 82 | type Signer struct { 83 | pw *io.PipeWriter 84 | done <-chan error 85 | sigParams map[string]string // only valid after done received nil 86 | } 87 | 88 | // NewSigner creates a new signer. It returns an error if SignOptions is 89 | // invalid. 90 | func NewSigner(options *SignOptions) (*Signer, error) { 91 | if options == nil { 92 | return nil, fmt.Errorf("dkim: no options specified") 93 | } 94 | if options.Domain == "" { 95 | return nil, fmt.Errorf("dkim: no domain specified") 96 | } 97 | if options.Selector == "" { 98 | return nil, fmt.Errorf("dkim: no selector specified") 99 | } 100 | if options.Signer == nil { 101 | return nil, fmt.Errorf("dkim: no signer specified") 102 | } 103 | 104 | headerCan := options.HeaderCanonicalization 105 | if headerCan == "" { 106 | headerCan = CanonicalizationSimple 107 | } 108 | if _, ok := canonicalizers[headerCan]; !ok { 109 | return nil, fmt.Errorf("dkim: unknown header canonicalization %q", headerCan) 110 | } 111 | 112 | bodyCan := options.BodyCanonicalization 113 | if bodyCan == "" { 114 | bodyCan = CanonicalizationSimple 115 | } 116 | if _, ok := canonicalizers[bodyCan]; !ok { 117 | return nil, fmt.Errorf("dkim: unknown body canonicalization %q", bodyCan) 118 | } 119 | 120 | var keyAlgo string 121 | switch options.Signer.Public().(type) { 122 | case *rsa.PublicKey: 123 | keyAlgo = "rsa" 124 | case ed25519.PublicKey: 125 | keyAlgo = "ed25519" 126 | default: 127 | return nil, fmt.Errorf("dkim: unsupported key algorithm %T", options.Signer.Public()) 128 | } 129 | 130 | hash := options.Hash 131 | var hashAlgo string 132 | switch options.Hash { 133 | case 0: // sha256 is the default 134 | hash = crypto.SHA256 135 | fallthrough 136 | case crypto.SHA256: 137 | hashAlgo = "sha256" 138 | case crypto.SHA1: 139 | return nil, fmt.Errorf("dkim: hash algorithm too weak: sha1") 140 | default: 141 | return nil, fmt.Errorf("dkim: unsupported hash algorithm") 142 | } 143 | 144 | if options.HeaderKeys != nil { 145 | ok := false 146 | for _, k := range options.HeaderKeys { 147 | if strings.EqualFold(k, "From") { 148 | ok = true 149 | break 150 | } 151 | } 152 | if !ok { 153 | return nil, fmt.Errorf("dkim: the From header field must be signed") 154 | } 155 | } 156 | 157 | done := make(chan error, 1) 158 | pr, pw := io.Pipe() 159 | 160 | s := &Signer{ 161 | pw: pw, 162 | done: done, 163 | } 164 | 165 | closeReadWithError := func(err error) { 166 | pr.CloseWithError(err) 167 | done <- err 168 | } 169 | 170 | go func() { 171 | defer close(done) 172 | 173 | // Read header 174 | br := bufio.NewReader(pr) 175 | h, err := readHeader(br) 176 | if err != nil { 177 | closeReadWithError(err) 178 | return 179 | } 180 | 181 | // Hash body 182 | hasher := hash.New() 183 | can := canonicalizers[bodyCan].CanonicalizeBody(hasher) 184 | if _, err := io.Copy(can, br); err != nil { 185 | closeReadWithError(err) 186 | return 187 | } 188 | if err := can.Close(); err != nil { 189 | closeReadWithError(err) 190 | return 191 | } 192 | bodyHashed := hasher.Sum(nil) 193 | 194 | params := map[string]string{ 195 | "v": "1", 196 | "a": keyAlgo + "-" + hashAlgo, 197 | "bh": base64.StdEncoding.EncodeToString(bodyHashed), 198 | "c": string(headerCan) + "/" + string(bodyCan), 199 | "d": options.Domain, 200 | //"l": "", // TODO 201 | "s": options.Selector, 202 | "t": formatTime(now()), 203 | //"z": "", // TODO 204 | } 205 | 206 | var headerKeys []string 207 | if options.HeaderKeys != nil { 208 | headerKeys = options.HeaderKeys 209 | } else { 210 | for _, kv := range h { 211 | k, _ := parseHeaderField(kv) 212 | headerKeys = append(headerKeys, k) 213 | } 214 | } 215 | params["h"] = formatTagList(headerKeys) 216 | 217 | if options.Identifier != "" { 218 | params["i"] = options.Identifier 219 | } 220 | 221 | if options.QueryMethods != nil { 222 | methods := make([]string, len(options.QueryMethods)) 223 | for i, method := range options.QueryMethods { 224 | methods[i] = string(method) 225 | } 226 | params["q"] = formatTagList(methods) 227 | } 228 | 229 | if !options.Expiration.IsZero() { 230 | params["x"] = formatTime(options.Expiration) 231 | } 232 | 233 | // Hash and sign headers 234 | hasher.Reset() 235 | picker := newHeaderPicker(h) 236 | for _, k := range headerKeys { 237 | kv := picker.Pick(k) 238 | if kv == "" { 239 | // The Signer MAY include more instances of a header field name 240 | // in "h=" than there are actual corresponding header fields so 241 | // that the signature will not verify if additional header 242 | // fields of that name are added. 243 | continue 244 | } 245 | 246 | kv = canonicalizers[headerCan].CanonicalizeHeader(kv) 247 | if _, err := io.WriteString(hasher, kv); err != nil { 248 | closeReadWithError(err) 249 | return 250 | } 251 | } 252 | 253 | params["b"] = "" 254 | sigField := formatSignature(params) 255 | sigField = canonicalizers[headerCan].CanonicalizeHeader(sigField) 256 | sigField = strings.TrimRight(sigField, crlf) 257 | if _, err := io.WriteString(hasher, sigField); err != nil { 258 | closeReadWithError(err) 259 | return 260 | } 261 | hashed := hasher.Sum(nil) 262 | 263 | // Don't pass Hash to Sign for ed25519 as it doesn't support it 264 | // and will return an error ("ed25519: cannot sign hashed message"). 265 | if keyAlgo == "ed25519" { 266 | hash = crypto.Hash(0) 267 | } 268 | 269 | sig, err := options.Signer.Sign(randReader, hashed, hash) 270 | if err != nil { 271 | closeReadWithError(err) 272 | return 273 | } 274 | params["b"] = base64.StdEncoding.EncodeToString(sig) 275 | 276 | s.sigParams = params 277 | closeReadWithError(nil) 278 | }() 279 | 280 | return s, nil 281 | } 282 | 283 | // Write implements io.WriteCloser. 284 | func (s *Signer) Write(b []byte) (n int, err error) { 285 | return s.pw.Write(b) 286 | } 287 | 288 | // Close implements io.WriteCloser. The error return by Close must be checked. 289 | func (s *Signer) Close() error { 290 | if err := s.pw.Close(); err != nil { 291 | return err 292 | } 293 | return <-s.done 294 | } 295 | 296 | // Signature returns the whole DKIM-Signature header field. It can only be 297 | // called after a successful Signer.Close call. 298 | // 299 | // The returned value contains both the header field name, its value and the 300 | // final CRLF. 301 | func (s *Signer) Signature() string { 302 | if s.sigParams == nil { 303 | panic("dkim: Signer.Signature must only be called after a succesful Signer.Close") 304 | } 305 | return formatSignature(s.sigParams) 306 | } 307 | 308 | // Sign signs a message. It reads it from r and writes the signed version to w. 309 | func Sign(w io.Writer, r io.Reader, options *SignOptions) error { 310 | s, err := NewSigner(options) 311 | if err != nil { 312 | return err 313 | } 314 | defer s.Close() 315 | 316 | // We need to keep the message in a buffer so we can write the new DKIM 317 | // header field before the rest of the message 318 | var b bytes.Buffer 319 | mw := io.MultiWriter(&b, s) 320 | 321 | if _, err := io.Copy(mw, r); err != nil { 322 | return err 323 | } 324 | if err := s.Close(); err != nil { 325 | return err 326 | } 327 | 328 | if _, err := io.WriteString(w, s.Signature()); err != nil { 329 | return err 330 | } 331 | _, err = io.Copy(w, &b) 332 | return err 333 | } 334 | 335 | func formatSignature(params map[string]string) string { 336 | sig := formatHeaderParams(headerFieldName, params) 337 | return sig 338 | } 339 | 340 | func formatTagList(l []string) string { 341 | return strings.Join(l, ":") 342 | } 343 | 344 | func formatTime(t time.Time) string { 345 | return strconv.FormatInt(t.Unix(), 10) 346 | } 347 | -------------------------------------------------------------------------------- /dkim/verify_test.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func newMailStringReader(s string) io.Reader { 14 | return strings.NewReader(strings.Replace(s, "\n", "\r\n", -1)) 15 | } 16 | 17 | const unsignedMailString = `From: Joe SixPack 18 | To: Suzie Q 19 | Subject: Is dinner ready? 20 | Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) 21 | Message-ID: <20030712040037.46341.5F8J@football.example.com> 22 | 23 | Hi. 24 | 25 | We lost the game. Are you hungry yet? 26 | 27 | Joe. 28 | ` 29 | 30 | func TestVerify_unsigned(t *testing.T) { 31 | r := newMailStringReader(unsignedMailString) 32 | 33 | verifications, err := Verify(r) 34 | if err != nil { 35 | t.Fatalf("Expected no error while verifying signature, got: %v", err) 36 | } else if len(verifications) != 0 { 37 | t.Fatalf("Expected exactly zero verification, got %v", len(verifications)) 38 | } 39 | } 40 | 41 | const verifiedMailString = `DKIM-Signature: v=1; a=rsa-sha256; s=brisbane; d=example.com; 42 | c=simple/simple; q=dns/txt; i=joe@football.example.com; 43 | h=Received : From : To : Subject : Date : Message-ID; 44 | bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; 45 | b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB 46 | 4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut 47 | KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV 48 | 4bmp/YzhwvcubU4=; 49 | Received: from client1.football.example.com [192.0.2.1] 50 | by submitserver.example.com with SUBMISSION; 51 | Fri, 11 Jul 2003 21:01:54 -0700 (PDT) 52 | From: Joe SixPack 53 | To: Suzie Q 54 | Subject: Is dinner ready? 55 | Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) 56 | Message-ID: <20030712040037.46341.5F8J@football.example.com> 57 | 58 | Hi. 59 | 60 | We lost the game. Are you hungry yet? 61 | 62 | Joe. 63 | ` 64 | 65 | var testVerification = &Verification{ 66 | Domain: "example.com", 67 | Identifier: "joe@football.example.com", 68 | HeaderKeys: []string{"Received", "From", "To", "Subject", "Date", "Message-ID"}, 69 | } 70 | 71 | func TestVerify(t *testing.T) { 72 | r := newMailStringReader(verifiedMailString) 73 | 74 | verifications, err := Verify(r) 75 | if err != nil { 76 | t.Fatalf("Expected no error while verifying signature, got: %v", err) 77 | } else if len(verifications) != 1 { 78 | t.Fatalf("Expected exactly one verification, got %v", len(verifications)) 79 | } 80 | 81 | v := verifications[0] 82 | if !reflect.DeepEqual(testVerification, v) { 83 | t.Errorf("Expected verification to be \n%+v\n but got \n%+v", testVerification, v) 84 | } 85 | } 86 | 87 | func TestVerifyWithOption(t *testing.T) { 88 | r := newMailStringReader(verifiedMailString) 89 | option := VerifyOptions{} 90 | verifications, err := VerifyWithOptions(r, &option) 91 | if err != nil { 92 | t.Fatalf("Expected no error while verifying signature, got: %v", err) 93 | } else if len(verifications) != 1 { 94 | t.Fatalf("Expected exactly one verification, got %v", len(verifications)) 95 | } 96 | 97 | v := verifications[0] 98 | if !reflect.DeepEqual(testVerification, v) { 99 | t.Errorf("Expected verification to be \n%+v\n but got \n%+v", testVerification, v) 100 | } 101 | 102 | r = newMailStringReader(verifiedMailString) 103 | option = VerifyOptions{LookupTXT: net.LookupTXT} 104 | verifications, err = VerifyWithOptions(r, &option) 105 | if err != nil { 106 | t.Fatalf("Expected no error while verifying signature, got: %v", err) 107 | } else if len(verifications) != 1 { 108 | t.Fatalf("Expected exactly one verification, got %v", len(verifications)) 109 | } 110 | 111 | v = verifications[0] 112 | if !reflect.DeepEqual(testVerification, v) { 113 | t.Errorf("Expected verification to be \n%+v\n but got \n%+v", testVerification, v) 114 | } 115 | } 116 | 117 | const verifiedRawRSAMailString = `DKIM-Signature: a=rsa-sha256; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; 118 | c=simple/simple; d=example.com; 119 | h=Received:From:To:Subject:Date:Message-ID; i=joe@football.example.com; 120 | s=newengland; t=1615825284; v=1; 121 | b=Xh4Ujb2wv5x54gXtulCiy4C0e+plRm6pZ4owF+kICpYzs/8WkTVIDBrzhJP0DAYCpnL62T0G 122 | k+0OH8pi/yqETVjKtKk+peMnNvKkut0GeWZMTze0bfq3/JUK3Ln3jTzzpXxrgVnvBxeY9EZIL4g 123 | s4wwFRRKz/1bksZGSjD8uuSU= 124 | Received: from client1.football.example.com [192.0.2.1] 125 | by submitserver.example.com with SUBMISSION; 126 | Fri, 11 Jul 2003 21:01:54 -0700 (PDT) 127 | From: Joe SixPack 128 | To: Suzie Q 129 | Subject: Is dinner ready? 130 | Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) 131 | Message-ID: <20030712040037.46341.5F8J@football.example.com> 132 | 133 | Hi. 134 | 135 | We lost the game. Are you hungry yet? 136 | 137 | Joe. 138 | ` 139 | 140 | var testRawRSAVerification = &Verification{ 141 | Domain: "example.com", 142 | Identifier: "joe@football.example.com", 143 | HeaderKeys: []string{"Received", "From", "To", "Subject", "Date", "Message-ID"}, 144 | Time: time.Unix(1615825284, 0), 145 | } 146 | 147 | func TestVerify_rawRSA(t *testing.T) { 148 | r := newMailStringReader(verifiedRawRSAMailString) 149 | 150 | verifications, err := Verify(r) 151 | if err != nil { 152 | t.Fatalf("Expected no error while verifying signature, got: %v", err) 153 | } else if len(verifications) != 1 { 154 | t.Fatalf("Expected exactly one verification, got %v", len(verifications)) 155 | } 156 | 157 | v := verifications[0] 158 | if !reflect.DeepEqual(testRawRSAVerification, v) { 159 | t.Errorf("Expected verification to be \n%+v\n but got \n%+v", testRawRSAVerification, v) 160 | } 161 | } 162 | 163 | const verifiedEd25519MailString = `DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; 164 | d=football.example.com; i=@football.example.com; 165 | q=dns/txt; s=brisbane; t=1528637909; h=from : to : 166 | subject : date : message-id : from : subject : date; 167 | bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; 168 | b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus 169 | Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== 170 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; 171 | d=football.example.com; i=@football.example.com; 172 | q=dns/txt; s=test; t=1528637909; h=from : to : subject : 173 | date : message-id : from : subject : date; 174 | bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; 175 | b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3 176 | DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz 177 | dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8= 178 | From: Joe SixPack 179 | To: Suzie Q 180 | Subject: Is dinner ready? 181 | Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) 182 | Message-ID: <20030712040037.46341.5F8J@football.example.com> 183 | 184 | Hi. 185 | 186 | We lost the game. Are you hungry yet? 187 | 188 | Joe.` 189 | 190 | var testEd25519Verification = &Verification{ 191 | Domain: "football.example.com", 192 | Identifier: "@football.example.com", 193 | HeaderKeys: []string{"from", "to", "subject", "date", "message-id", "from", "subject", "date"}, 194 | Time: time.Unix(1528637909, 0), 195 | } 196 | 197 | func TestVerify_ed25519(t *testing.T) { 198 | r := newMailStringReader(verifiedEd25519MailString) 199 | 200 | verifications, err := Verify(r) 201 | if err != nil { 202 | t.Fatalf("Expected no error while verifying signature, got: %v", err) 203 | } else if len(verifications) != 2 { 204 | t.Fatalf("Expected exactly two verifications, got %v", len(verifications)) 205 | } 206 | 207 | v := verifications[0] 208 | if !reflect.DeepEqual(testEd25519Verification, v) { 209 | t.Errorf("Expected verification to be \n%+v\n but got \n%+v", testEd25519Verification, v) 210 | } 211 | } 212 | 213 | // errorReader reads from r and then returns an arbitrary error. 214 | type errorReader struct { 215 | r io.Reader 216 | err error 217 | } 218 | 219 | func (r *errorReader) Read(b []byte) (int, error) { 220 | n, err := r.r.Read(b) 221 | if err == io.EOF { 222 | return n, r.err 223 | } 224 | return n, err 225 | } 226 | 227 | func TestVerify_invalid(t *testing.T) { 228 | r := newMailStringReader("asdf") 229 | _, err := Verify(r) 230 | if err == nil { 231 | t.Fatalf("Expected error while verifying signature, got nil") 232 | } 233 | 234 | expectedErr := errors.New("expected test error") 235 | 236 | r = &errorReader{ 237 | r: newMailStringReader(verifiedEd25519MailString), 238 | err: expectedErr, 239 | } 240 | _, err = Verify(r) 241 | if err != expectedErr { 242 | t.Fatalf("Expected error while verifying signature, got: %v", err) 243 | } 244 | } 245 | 246 | const tooManySignaturesMailString = `DKIM-Signature: v=1; a=rsa-sha256; s=brisbane; d=example.com; 247 | c=simple/simple; q=dns/txt; i=joe@football.example.com; 248 | h=Received : From : To : Subject : Date : Message-ID; 249 | bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; 250 | b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB 251 | 4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut 252 | KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV 253 | 4bmp/YzhwvcubU4=; 254 | DKIM-Signature: v=1; a=rsa-sha256; s=brisbane; d=example.com; 255 | c=simple/simple; q=dns/txt; i=joe@football.example.com; 256 | h=Received : From : To : Subject : Date : Message-ID; 257 | bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; 258 | b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB 259 | 4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut 260 | KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV 261 | 4bmp/YzhwvcubU4=; 262 | DKIM-Signature: v=1; a=rsa-sha256; s=brisbane; d=example.com; 263 | c=simple/simple; q=dns/txt; i=joe@football.example.com; 264 | h=Received : From : To : Subject : Date : Message-ID; 265 | bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; 266 | b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB 267 | 4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut 268 | KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV 269 | 4bmp/YzhwvcubU4=; 270 | Received: from client1.football.example.com [192.0.2.1] 271 | by submitserver.example.com with SUBMISSION; 272 | Fri, 11 Jul 2003 21:01:54 -0700 (PDT) 273 | From: Joe SixPack 274 | To: Suzie Q 275 | Subject: Is dinner ready? 276 | Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) 277 | Message-ID: <20030712040037.46341.5F8J@football.example.com> 278 | 279 | Hi. 280 | 281 | We lost the game. Are you hungry yet? 282 | 283 | Joe. 284 | ` 285 | 286 | func TestVerify_tooManySignatures(t *testing.T) { 287 | r := strings.NewReader(tooManySignaturesMailString) 288 | options := VerifyOptions{MaxVerifications: 2} 289 | verifs, err := VerifyWithOptions(r, &options) 290 | if err != ErrTooManySignatures { 291 | t.Fatalf("Expected ErrTooManySignatures, got %v", err) 292 | } 293 | if len(verifs) != options.MaxVerifications { 294 | t.Fatalf("Expected %v verifications, got %v", options.MaxVerifications, len(verifs)) 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /cmd/dkim-milter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "flag" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "log" 13 | "net" 14 | "net/mail" 15 | "net/textproto" 16 | "os" 17 | "os/signal" 18 | "path" 19 | "strings" 20 | "syscall" 21 | 22 | "github.com/emersion/go-milter" 23 | "github.com/emersion/go-msgauth/authres" 24 | "github.com/emersion/go-msgauth/dkim" 25 | "golang.org/x/crypto/ed25519" 26 | ) 27 | 28 | var ( 29 | signDomains stringSliceFlag 30 | identity string 31 | listenURI string 32 | privateKeyPath string 33 | selector string 34 | verbose bool 35 | ) 36 | 37 | var privateKey crypto.Signer 38 | 39 | var signHeaderKeys = []string{ 40 | "From", 41 | "Reply-To", 42 | "Subject", 43 | "Date", 44 | "To", 45 | "Cc", 46 | "Resent-Date", 47 | "Resent-From", 48 | "Resent-To", 49 | "Resent-Cc", 50 | "In-Reply-To", 51 | "References", 52 | "List-Id", 53 | "List-Help", 54 | "List-Unsubscribe", 55 | "List-Subscribe", 56 | "List-Post", 57 | "List-Owner", 58 | "List-Archive", 59 | } 60 | 61 | const maxVerifications = 5 62 | 63 | const usage = `usage: dkim-milter [options....] 64 | 65 | Mail filter to verify and sign messages with DKIM. 66 | 67 | By default, message signatures are verified and an Authentication-Results 68 | header field is inserted. 69 | 70 | When -d, -k and -s are provided, messages matching the domain(s) are signed. 71 | dkim-keygen can be used to generate a private key. 72 | 73 | Options: 74 | ` 75 | 76 | func init() { 77 | flag.Var(&signDomains, "d", "Domain(s) whose mail should be signed (glob patterns are allowed)") 78 | flag.StringVar(&identity, "i", "", "Server identity (defaults to hostname)") 79 | flag.StringVar(&listenURI, "l", "unix:///tmp/dkim-milter.sock", "Listen URI") 80 | flag.StringVar(&privateKeyPath, "k", "", "Private key (PEM-formatted)") 81 | flag.StringVar(&selector, "s", "", "Selector") 82 | flag.BoolVar(&verbose, "v", false, "Enable verbose logging") 83 | 84 | flag.Usage = func() { 85 | fmt.Fprintf(flag.CommandLine.Output(), usage) 86 | flag.PrintDefaults() 87 | } 88 | } 89 | 90 | type stringSliceFlag []string 91 | 92 | func (f *stringSliceFlag) String() string { 93 | return strings.Join(*f, ", ") 94 | } 95 | 96 | func (f *stringSliceFlag) Set(value string) error { 97 | *f = append(*f, value) 98 | return nil 99 | } 100 | 101 | type session struct { 102 | milter.NoOpMilter 103 | 104 | authResDelete []int 105 | headerBuf bytes.Buffer 106 | 107 | signDomain string 108 | signHeaderKeys []string 109 | 110 | done <-chan error 111 | pw *io.PipeWriter 112 | verifs []*dkim.Verification // only valid after done is closed 113 | signer *dkim.Signer 114 | mw io.Writer 115 | } 116 | 117 | func parseAddressDomain(s string) (string, error) { 118 | addr, err := mail.ParseAddress(s) 119 | if err != nil { 120 | return "", err 121 | } 122 | 123 | parts := strings.SplitN(addr.Address, "@", 2) 124 | if len(parts) != 2 { 125 | return "", fmt.Errorf("dkim-milter: malformed address: missing '@'") 126 | } 127 | 128 | return parts[1], nil 129 | } 130 | 131 | func (s *session) Header(name string, value string, m *milter.Modifier) (milter.Response, error) { 132 | if strings.EqualFold(name, "From") || strings.EqualFold(name, "Sender") { 133 | domain, err := parseAddressDomain(value) 134 | if err != nil { 135 | return nil, fmt.Errorf("dkim-milter: failed to parse header field %q: %v", name, err) 136 | } 137 | domain = strings.ToLower(domain) 138 | 139 | for _, pattern := range signDomains { 140 | if ok, err := path.Match(pattern, domain); err != nil { 141 | return nil, fmt.Errorf("dkim-milter: failed to match domain %q: %v", domain, err) 142 | } else if ok { 143 | s.signDomain = domain 144 | break 145 | } 146 | } 147 | } 148 | 149 | for _, k := range signHeaderKeys { 150 | if strings.EqualFold(name, k) { 151 | s.signHeaderKeys = append(s.signHeaderKeys, name) 152 | } 153 | } 154 | 155 | field := name + ": " + value + "\r\n" 156 | _, err := s.headerBuf.WriteString(field) 157 | return milter.RespContinue, err 158 | } 159 | 160 | func getIdentity(authRes string) string { 161 | parts := strings.SplitN(authRes, ";", 2) 162 | return strings.TrimSpace(parts[0]) 163 | } 164 | 165 | func shouldDeleteAuthRes(field string) bool { 166 | id, results, err := authres.Parse(field) 167 | if err != nil { 168 | // Delete fields we can't parse, because other implementations might 169 | // accept malformed fields 170 | return true 171 | } 172 | 173 | if !strings.EqualFold(id, identity) { 174 | // Not our Authentication-Results, ignore the field 175 | return false 176 | } 177 | 178 | for _, res := range results { 179 | if _, ok := res.(*authres.DKIMResult); ok { 180 | // Delete existing DKIM Authentication-Results fields 181 | return true 182 | } 183 | } 184 | 185 | // This is our Authentication-Results field, but it isn't about DKIM. Maybe 186 | // a previous milter has generated it (e.g. SPF), so keep it. 187 | return false 188 | } 189 | 190 | func (s *session) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) { 191 | // Write final CRLF to begin message body 192 | if _, err := s.headerBuf.WriteString("\r\n"); err != nil { 193 | return nil, err 194 | } 195 | 196 | // Delete any existing Authentication-Results header field with our identity 197 | fields := h["Authentication-Results"] 198 | for i, field := range fields { 199 | if shouldDeleteAuthRes(field) { 200 | s.authResDelete = append(s.authResDelete, i) 201 | } 202 | } 203 | 204 | // Sign if necessary 205 | if s.signDomain != "" { 206 | opts := dkim.SignOptions{ 207 | Domain: s.signDomain, 208 | Selector: selector, 209 | Signer: privateKey, 210 | HeaderKeys: s.signHeaderKeys, 211 | QueryMethods: []dkim.QueryMethod{dkim.QueryMethodDNSTXT}, 212 | } 213 | 214 | var err error 215 | s.signer, err = dkim.NewSigner(&opts) 216 | if err != nil { 217 | return nil, err 218 | } 219 | } 220 | 221 | // Verify existing signatures 222 | done := make(chan error, 1) 223 | pr, pw := io.Pipe() 224 | 225 | s.done = done 226 | s.pw = pw 227 | 228 | // TODO: limit max. number of signatures 229 | go func() { 230 | options := dkim.VerifyOptions{MaxVerifications: maxVerifications} 231 | 232 | var err error 233 | s.verifs, err = dkim.VerifyWithOptions(pr, &options) 234 | io.Copy(ioutil.Discard, pr) 235 | pr.Close() 236 | done <- err 237 | close(done) 238 | }() 239 | 240 | // Process header 241 | return s.BodyChunk(s.headerBuf.Bytes(), m) 242 | } 243 | 244 | func (s *session) BodyChunk(chunk []byte, m *milter.Modifier) (milter.Response, error) { 245 | if _, err := s.pw.Write(chunk); err != nil { 246 | return nil, err 247 | } 248 | if s.signer != nil { 249 | if _, err := s.signer.Write(chunk); err != nil { 250 | return nil, err 251 | } 252 | } 253 | return milter.RespContinue, nil 254 | } 255 | 256 | func (s *session) Body(m *milter.Modifier) (milter.Response, error) { 257 | if err := s.pw.Close(); err != nil { 258 | return nil, err 259 | } 260 | 261 | for _, index := range s.authResDelete { 262 | if err := m.ChangeHeader(index, "Authentication-Results", ""); err != nil { 263 | return nil, err 264 | } 265 | } 266 | 267 | if err := <-s.done; err == dkim.ErrTooManySignatures { 268 | if verbose { 269 | log.Printf("Too many signatures in message: %v", err) 270 | } 271 | // Ignore the error 272 | } else if err != nil { 273 | if verbose { 274 | log.Printf("DKIM verification failed: %v", err) 275 | } 276 | return nil, err 277 | } 278 | 279 | if s.signer != nil { 280 | if err := s.signer.Close(); err != nil { 281 | if verbose { 282 | log.Printf("DKIM signature failed: %v", err) 283 | } 284 | return nil, err 285 | } 286 | 287 | kv := s.signer.Signature() 288 | parts := strings.SplitN(kv, ": ", 2) 289 | if len(parts) != 2 { 290 | panic("dkim-milter: malformed DKIM-Signature header field") 291 | } 292 | k, v := parts[0], strings.TrimSuffix(parts[1], "\r\n") 293 | 294 | if err := m.InsertHeader(0, k, v); err != nil { 295 | return nil, err 296 | } 297 | } 298 | 299 | results := make([]authres.Result, 0, len(s.verifs)) 300 | 301 | if len(s.verifs) == 0 && s.signer == nil { 302 | results = append(results, &authres.DKIMResult{ 303 | Value: authres.ResultNone, 304 | }) 305 | } 306 | 307 | for _, verif := range s.verifs { 308 | if verbose { 309 | if verif.Err != nil { 310 | log.Printf("DKIM verification failed for %v: %v", verif.Domain, verif.Err) 311 | } else { 312 | log.Printf("DKIM verification succeded for %v", verif.Domain) 313 | } 314 | } 315 | 316 | var val authres.ResultValue 317 | if verif.Err == nil { 318 | val = authres.ResultPass 319 | } else if dkim.IsPermFail(verif.Err) { 320 | val = authres.ResultPermError 321 | } else if dkim.IsTempFail(verif.Err) { 322 | val = authres.ResultTempError 323 | } else { 324 | val = authres.ResultFail 325 | } 326 | 327 | results = append(results, &authres.DKIMResult{ 328 | Value: val, 329 | Domain: verif.Domain, 330 | Identifier: verif.Identifier, 331 | }) 332 | } 333 | 334 | if len(s.verifs) > 0 || s.signer == nil { 335 | v := authres.Format(identity, results) 336 | if err := m.InsertHeader(0, "Authentication-Results", v); err != nil { 337 | return nil, err 338 | } 339 | } 340 | 341 | return milter.RespAccept, nil 342 | } 343 | 344 | func loadPrivateKey(path string) (crypto.Signer, error) { 345 | b, err := ioutil.ReadFile(privateKeyPath) 346 | if err != nil { 347 | return nil, err 348 | } 349 | 350 | block, _ := pem.Decode(b) 351 | if block == nil { 352 | return nil, fmt.Errorf("no PEM data found") 353 | } 354 | 355 | switch strings.ToUpper(block.Type) { 356 | case "PRIVATE KEY": 357 | k, err := x509.ParsePKCS8PrivateKey(block.Bytes) 358 | if err != nil { 359 | return nil, err 360 | } 361 | return k.(crypto.Signer), nil 362 | case "RSA PRIVATE KEY": 363 | return x509.ParsePKCS1PrivateKey(block.Bytes) 364 | case "EDDSA PRIVATE KEY": 365 | if len(block.Bytes) != ed25519.PrivateKeySize { 366 | return nil, fmt.Errorf("invalid Ed25519 private key size") 367 | } 368 | return ed25519.PrivateKey(block.Bytes), nil 369 | default: 370 | return nil, fmt.Errorf("unknown private key type: '%v'", block.Type) 371 | } 372 | } 373 | 374 | func main() { 375 | flag.Parse() 376 | 377 | if identity == "" { 378 | var err error 379 | identity, err = os.Hostname() 380 | if err != nil { 381 | log.Fatal("Failed to read hostname: ", err) 382 | } 383 | } 384 | 385 | if (len(signDomains) > 0 || privateKeyPath != "" || selector != "") && !(len(signDomains) > 0 && privateKeyPath != "" && selector != "") { 386 | log.Fatal("Domain(s) (-d), private key (-k) and selector (-s) must all be specified") 387 | } 388 | 389 | for i, pattern := range signDomains { 390 | if _, err := path.Match(pattern, ""); err != nil { 391 | log.Fatalf("Malformed domain pattern %q: %v", pattern, err) 392 | } 393 | signDomains[i] = strings.ToLower(pattern) 394 | } 395 | 396 | if privateKeyPath != "" { 397 | var err error 398 | privateKey, err = loadPrivateKey(privateKeyPath) 399 | if err != nil { 400 | log.Fatalf("Failed to load private key from '%v': %v", privateKeyPath, err) 401 | } 402 | } 403 | 404 | parts := strings.SplitN(listenURI, "://", 2) 405 | if len(parts) != 2 { 406 | log.Fatal("Invalid listen URI") 407 | } 408 | listenNetwork, listenAddr := parts[0], parts[1] 409 | 410 | s := milter.Server{ 411 | NewMilter: func() milter.Milter { 412 | return &session{} 413 | }, 414 | Actions: milter.OptAddHeader | milter.OptChangeHeader, 415 | Protocol: milter.OptNoConnect | milter.OptNoHelo | milter.OptNoMailFrom | milter.OptNoRcptTo, 416 | } 417 | 418 | ln, err := net.Listen(listenNetwork, listenAddr) 419 | if err != nil { 420 | log.Fatal("Failed to setup listener: ", err) 421 | } 422 | 423 | // Closing the listener will unlink the unix socket, if any 424 | sigs := make(chan os.Signal, 1) 425 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 426 | go func() { 427 | <-sigs 428 | if err := s.Close(); err != nil { 429 | log.Fatal("Failed to close server: ", err) 430 | } 431 | }() 432 | 433 | log.Println("Milter listening at", listenURI) 434 | if err := s.Serve(ln); err != nil && err != milter.ErrServerClosed { 435 | log.Fatal("Failed to serve: ", err) 436 | } 437 | } 438 | -------------------------------------------------------------------------------- /dkim/verify.go: -------------------------------------------------------------------------------- 1 | package dkim 2 | 3 | import ( 4 | "bufio" 5 | "crypto" 6 | "crypto/subtle" 7 | "encoding/base64" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "time" 16 | "unicode" 17 | ) 18 | 19 | type permFailError string 20 | 21 | func (err permFailError) Error() string { 22 | return "dkim: " + string(err) 23 | } 24 | 25 | // IsPermFail returns true if the error returned by Verify is a permanent 26 | // failure. A permanent failure is for instance a missing required field or a 27 | // malformed header. 28 | func IsPermFail(err error) bool { 29 | _, ok := err.(permFailError) 30 | return ok 31 | } 32 | 33 | type tempFailError string 34 | 35 | func (err tempFailError) Error() string { 36 | return "dkim: " + string(err) 37 | } 38 | 39 | // IsTempFail returns true if the error returned by Verify is a temporary 40 | // failure. 41 | func IsTempFail(err error) bool { 42 | _, ok := err.(tempFailError) 43 | return ok 44 | } 45 | 46 | type failError string 47 | 48 | func (err failError) Error() string { 49 | return "dkim: " + string(err) 50 | } 51 | 52 | // isFail returns true if the error returned by Verify is a signature error. 53 | func isFail(err error) bool { 54 | _, ok := err.(failError) 55 | return ok 56 | } 57 | 58 | // ErrTooManySignatures is returned by Verify when the message exceeds the 59 | // maximum number of signatures. 60 | var ErrTooManySignatures = errors.New("dkim: too many signatures") 61 | 62 | var requiredTags = []string{"v", "a", "b", "bh", "d", "h", "s"} 63 | 64 | // A Verification is produced by Verify when it checks if one signature is 65 | // valid. If the signature is valid, Err is nil. 66 | type Verification struct { 67 | // The SDID claiming responsibility for an introduction of a message into the 68 | // mail stream. 69 | Domain string 70 | // The Agent or User Identifier (AUID) on behalf of which the SDID is taking 71 | // responsibility. 72 | Identifier string 73 | 74 | // The list of signed header fields. 75 | HeaderKeys []string 76 | 77 | // The time that this signature was created. If unknown, it's set to zero. 78 | Time time.Time 79 | // The expiration time. If the signature doesn't expire, it's set to zero. 80 | Expiration time.Time 81 | 82 | // Err is nil if the signature is valid. 83 | Err error 84 | } 85 | 86 | type signature struct { 87 | i int 88 | v string 89 | } 90 | 91 | // VerifyOptions allows to customize the default signature verification 92 | // behavior. 93 | type VerifyOptions struct { 94 | // LookupTXT returns the DNS TXT records for the given domain name. If nil, 95 | // net.LookupTXT is used. 96 | LookupTXT func(domain string) ([]string, error) 97 | // MaxVerifications controls the maximum number of signature verifications 98 | // to perform. If more signatures are present, the first MaxVerifications 99 | // signatures are verified, the rest are ignored and ErrTooManySignatures 100 | // is returned. If zero, there is no maximum. 101 | MaxVerifications int 102 | } 103 | 104 | // Verify checks if a message's signatures are valid. It returns one 105 | // verification per signature. 106 | // 107 | // There is no guarantee that the reader will be completely consumed. 108 | func Verify(r io.Reader) ([]*Verification, error) { 109 | return VerifyWithOptions(r, nil) 110 | } 111 | 112 | // VerifyWithOptions performs the same task as Verify, but allows specifying 113 | // verification options. 114 | func VerifyWithOptions(r io.Reader, options *VerifyOptions) ([]*Verification, error) { 115 | // Read header 116 | bufr := bufio.NewReader(r) 117 | h, err := readHeader(bufr) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | // Scan header fields for signatures 123 | var signatures []*signature 124 | for i, kv := range h { 125 | k, v := parseHeaderField(kv) 126 | if strings.EqualFold(k, headerFieldName) { 127 | signatures = append(signatures, &signature{i, v}) 128 | } 129 | } 130 | 131 | tooManySignatures := false 132 | if options != nil && options.MaxVerifications > 0 && len(signatures) > options.MaxVerifications { 133 | tooManySignatures = true 134 | signatures = signatures[:options.MaxVerifications] 135 | } 136 | 137 | var verifs []*Verification 138 | if len(signatures) == 1 { 139 | // If there is only one signature - just verify it. 140 | v, err := verify(h, bufr, h[signatures[0].i], signatures[0].v, options) 141 | if err != nil && !IsTempFail(err) && !IsPermFail(err) && !isFail(err) { 142 | return nil, err 143 | } 144 | v.Err = err 145 | verifs = []*Verification{v} 146 | } else { 147 | verifs, err = parallelVerify(bufr, h, signatures, options) 148 | if err != nil { 149 | return nil, err 150 | } 151 | } 152 | 153 | if tooManySignatures { 154 | return verifs, ErrTooManySignatures 155 | } 156 | return verifs, nil 157 | } 158 | 159 | func parallelVerify(r io.Reader, h header, signatures []*signature, options *VerifyOptions) ([]*Verification, error) { 160 | pipeWriters := make([]*io.PipeWriter, len(signatures)) 161 | // We can't pass pipeWriter to io.MultiWriter directly, 162 | // we need a slice of io.Writer, but we also need *io.PipeWriter 163 | // to call Close on it. 164 | writers := make([]io.Writer, len(signatures)) 165 | chans := make([]chan *Verification, len(signatures)) 166 | 167 | for i, sig := range signatures { 168 | // Be careful with loop variables and goroutines. 169 | i, sig := i, sig 170 | 171 | chans[i] = make(chan *Verification, 1) 172 | 173 | pr, pw := io.Pipe() 174 | writers[i] = pw 175 | pipeWriters[i] = pw 176 | 177 | go func() { 178 | v, err := verify(h, pr, h[sig.i], sig.v, options) 179 | 180 | // Make sure we consume the whole reader, otherwise io.Copy on 181 | // other side can block forever. 182 | io.Copy(ioutil.Discard, pr) 183 | 184 | v.Err = err 185 | chans[i] <- v 186 | }() 187 | } 188 | 189 | if _, err := io.Copy(io.MultiWriter(writers...), r); err != nil { 190 | return nil, err 191 | } 192 | for _, wr := range pipeWriters { 193 | wr.Close() 194 | } 195 | 196 | verifications := make([]*Verification, len(signatures)) 197 | for i, ch := range chans { 198 | verifications[i] = <-ch 199 | } 200 | 201 | // Return unexpected failures as a separate error. 202 | for _, v := range verifications { 203 | err := v.Err 204 | if err != nil && !IsTempFail(err) && !IsPermFail(err) && !isFail(err) { 205 | v.Err = nil 206 | return verifications, err 207 | } 208 | } 209 | return verifications, nil 210 | } 211 | 212 | func verify(h header, r io.Reader, sigField, sigValue string, options *VerifyOptions) (*Verification, error) { 213 | verif := new(Verification) 214 | 215 | params, err := parseHeaderParams(sigValue) 216 | if err != nil { 217 | return verif, permFailError("malformed signature tags: " + err.Error()) 218 | } 219 | 220 | if params["v"] != "1" { 221 | return verif, permFailError("incompatible signature version") 222 | } 223 | 224 | verif.Domain = stripWhitespace(params["d"]) 225 | 226 | for _, tag := range requiredTags { 227 | if _, ok := params[tag]; !ok { 228 | return verif, permFailError("signature missing required tag") 229 | } 230 | } 231 | 232 | if i, ok := params["i"]; ok { 233 | verif.Identifier = stripWhitespace(i) 234 | if !strings.HasSuffix(verif.Identifier, "@"+verif.Domain) && !strings.HasSuffix(verif.Identifier, "."+verif.Domain) { 235 | return verif, permFailError("domain mismatch") 236 | } 237 | } else { 238 | verif.Identifier = "@" + verif.Domain 239 | } 240 | 241 | headerKeys := parseTagList(params["h"]) 242 | ok := false 243 | for _, k := range headerKeys { 244 | if strings.EqualFold(k, "from") { 245 | ok = true 246 | break 247 | } 248 | } 249 | if !ok { 250 | return verif, permFailError("From field not signed") 251 | } 252 | verif.HeaderKeys = headerKeys 253 | 254 | if timeStr, ok := params["t"]; ok { 255 | t, err := parseTime(timeStr) 256 | if err != nil { 257 | return verif, permFailError("malformed time: " + err.Error()) 258 | } 259 | verif.Time = t 260 | } 261 | if expiresStr, ok := params["x"]; ok { 262 | t, err := parseTime(expiresStr) 263 | if err != nil { 264 | return verif, permFailError("malformed expiration time: " + err.Error()) 265 | } 266 | verif.Expiration = t 267 | if now().After(t) { 268 | return verif, permFailError("signature has expired") 269 | } 270 | } 271 | 272 | // Query public key 273 | // TODO: compute hash in parallel 274 | methods := []string{string(QueryMethodDNSTXT)} 275 | if methodsStr, ok := params["q"]; ok { 276 | methods = parseTagList(methodsStr) 277 | } 278 | var res *queryResult 279 | for _, method := range methods { 280 | if query, ok := queryMethods[QueryMethod(method)]; ok { 281 | if options != nil { 282 | res, err = query(verif.Domain, stripWhitespace(params["s"]), options.LookupTXT) 283 | } else { 284 | res, err = query(verif.Domain, stripWhitespace(params["s"]), nil) 285 | } 286 | break 287 | } 288 | } 289 | if err != nil { 290 | return verif, err 291 | } else if res == nil { 292 | return verif, permFailError("unsupported public key query method") 293 | } 294 | 295 | // Parse algos 296 | keyAlgo, hashAlgo, ok := strings.Cut(stripWhitespace(params["a"]), "-") 297 | if !ok { 298 | return verif, permFailError("malformed algorithm name") 299 | } 300 | 301 | // Check hash algo 302 | if res.HashAlgos != nil { 303 | ok := false 304 | for _, algo := range res.HashAlgos { 305 | if algo == hashAlgo { 306 | ok = true 307 | break 308 | } 309 | } 310 | if !ok { 311 | return verif, permFailError("inappropriate hash algorithm") 312 | } 313 | } 314 | var hash crypto.Hash 315 | switch hashAlgo { 316 | case "sha1": 317 | // RFC 8301 section 3.1: rsa-sha1 MUST NOT be used for signing or 318 | // verifying. 319 | return verif, permFailError(fmt.Sprintf("hash algorithm too weak: %v", hashAlgo)) 320 | case "sha256": 321 | hash = crypto.SHA256 322 | default: 323 | return verif, permFailError("unsupported hash algorithm") 324 | } 325 | 326 | // Check key algo 327 | if res.KeyAlgo != keyAlgo { 328 | return verif, permFailError("inappropriate key algorithm") 329 | } 330 | 331 | if res.Services != nil { 332 | ok := false 333 | for _, s := range res.Services { 334 | if s == "email" { 335 | ok = true 336 | break 337 | } 338 | } 339 | if !ok { 340 | return verif, permFailError("inappropriate service") 341 | } 342 | } 343 | 344 | headerCan, bodyCan := parseCanonicalization(params["c"]) 345 | if _, ok := canonicalizers[headerCan]; !ok { 346 | return verif, permFailError("unsupported header canonicalization algorithm") 347 | } 348 | if _, ok := canonicalizers[bodyCan]; !ok { 349 | return verif, permFailError("unsupported body canonicalization algorithm") 350 | } 351 | 352 | // The body length "l" parameter is insecure, because it allows parts of 353 | // the message body to not be signed. Reject messages which have it set. 354 | if _, ok := params["l"]; ok { 355 | // TODO: technically should be policyError 356 | return verif, failError("message contains an insecure body length tag") 357 | } 358 | 359 | // Parse body hash and signature 360 | bodyHashed, err := decodeBase64String(params["bh"]) 361 | if err != nil { 362 | return verif, permFailError("malformed body hash: " + err.Error()) 363 | } 364 | sig, err := decodeBase64String(params["b"]) 365 | if err != nil { 366 | return verif, permFailError("malformed signature: " + err.Error()) 367 | } 368 | 369 | // Check body hash 370 | hasher := hash.New() 371 | wc := canonicalizers[bodyCan].CanonicalizeBody(hasher) 372 | if _, err := io.Copy(wc, r); err != nil { 373 | return verif, err 374 | } 375 | if err := wc.Close(); err != nil { 376 | return verif, err 377 | } 378 | if subtle.ConstantTimeCompare(hasher.Sum(nil), bodyHashed) != 1 { 379 | return verif, failError("body hash did not verify") 380 | } 381 | 382 | // Compute data hash 383 | hasher.Reset() 384 | picker := newHeaderPicker(h) 385 | for _, key := range headerKeys { 386 | kv := picker.Pick(key) 387 | if kv == "" { 388 | // The field MAY contain names of header fields that do not exist 389 | // when signed; nonexistent header fields do not contribute to the 390 | // signature computation 391 | continue 392 | } 393 | 394 | kv = canonicalizers[headerCan].CanonicalizeHeader(kv) 395 | if _, err := hasher.Write([]byte(kv)); err != nil { 396 | return verif, err 397 | } 398 | } 399 | canSigField := removeSignature(sigField) 400 | canSigField = canonicalizers[headerCan].CanonicalizeHeader(canSigField) 401 | canSigField = strings.TrimRight(canSigField, "\r\n") 402 | if _, err := hasher.Write([]byte(canSigField)); err != nil { 403 | return verif, err 404 | } 405 | hashed := hasher.Sum(nil) 406 | 407 | // Check signature 408 | if err := res.Verifier.Verify(hash, hashed, sig); err != nil { 409 | return verif, failError("signature did not verify: " + err.Error()) 410 | } 411 | 412 | return verif, nil 413 | } 414 | 415 | func parseTagList(s string) []string { 416 | tags := strings.Split(s, ":") 417 | for i, t := range tags { 418 | tags[i] = stripWhitespace(t) 419 | } 420 | return tags 421 | } 422 | 423 | func parseCanonicalization(s string) (headerCan, bodyCan Canonicalization) { 424 | headerCan = CanonicalizationSimple 425 | bodyCan = CanonicalizationSimple 426 | 427 | cans := strings.SplitN(stripWhitespace(s), "/", 2) 428 | if cans[0] != "" { 429 | headerCan = Canonicalization(cans[0]) 430 | } 431 | if len(cans) > 1 { 432 | bodyCan = Canonicalization(cans[1]) 433 | } 434 | return 435 | } 436 | 437 | func parseTime(s string) (time.Time, error) { 438 | sec, err := strconv.ParseInt(stripWhitespace(s), 10, 64) 439 | if err != nil { 440 | return time.Time{}, err 441 | } 442 | return time.Unix(sec, 0), nil 443 | } 444 | 445 | func decodeBase64String(s string) ([]byte, error) { 446 | return base64.StdEncoding.DecodeString(stripWhitespace(s)) 447 | } 448 | 449 | func stripWhitespace(s string) string { 450 | return strings.Map(func(r rune) rune { 451 | if unicode.IsSpace(r) { 452 | return -1 453 | } 454 | return r 455 | }, s) 456 | } 457 | 458 | var sigRegex = regexp.MustCompile(`(b\s*=)[^;]+`) 459 | 460 | func removeSignature(s string) string { 461 | return sigRegex.ReplaceAllString(s, "$1") 462 | } 463 | --------------------------------------------------------------------------------