├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── cmd ├── hpkp-headers │ └── main.go └── hpkp-pins │ └── main.go ├── dialer.go ├── example_test.go ├── fingerprint.go ├── fingerprint_test.go ├── header.go ├── header_test.go ├── report.go ├── report_test.go ├── storage.go └── storage_test.go /.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 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.6 4 | - tip 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Tommy Murphy 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 | # hpkp 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/tam7t/hpkp?style=flat-square)](https://goreportcard.com/report/github.com/tam7t/hpkp) [![GoDoc](http://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](http://godoc.org/github.com/tam7t/hpkp) [![Build Status](http://img.shields.io/travis/tam7t/hpkp.svg?style=flat-square)](https://travis-ci.org/tam7t/hpkp) 3 | 4 | Library for performing certificate pin validation for golang applications. 5 | 6 | ## Motivation 7 | 8 | I couldn't find any Golang libraries that make key pinning any easier, so I decided to start my own library for writing HPKP aware clients. This library is aimed at providing: 9 | 10 | 1. HPKP related tools (generate pins, inspect servers) 11 | 1. A convenience functions for writing clients that support pin verification 12 | 13 | 14 | ## Examples 15 | 16 | To inspect the HPKP headers from the server: 17 | 18 | ``` 19 | $ hpkp-headers https://github.com 20 | {"Created":1465765483,"MaxAge":5184000,"IncludeSubDomains":true,"Permanent":false,"Sha256Pins":["WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18=","RRM1dGqnDFsCJXBTHky16vi1obOlCgFFn/yOhI/y+ho=","k2v657xBsOVe1PQRwOsHsw3bsGT2VzIqz5K+59sNQws=","K87oWBWM9UZfyddvDfoxL+8lpNyoUB2ptGtn0fv6G2Q=","IQBnNBEiFuhj+8x6X8XLgh01V9Ic5/V3IRQLNFFc7v4=","iie1VXtL7HzAMF+/PVPR9xzT80kQxdZeJ+zduCB3uj0=","LvRiGEjRqfzurezaWuj8Wie2gyHMrW5Q06LspMnox7A="]} 21 | ``` 22 | 23 | And generate pins from the certs a server presents: 24 | 25 | ``` 26 | $ hpkp-pins -server=github.com:443 27 | pL1+qb9HTMRZJmuC/bB/ZI9d302BYrrqiVuRyW+DGrU= 28 | RRM1dGqnDFsCJXBTHky16vi1obOlCgFFn/yOhI/y+ho= 29 | ``` 30 | 31 | Or generate a pin from a PEM-encoded certificate file: 32 | 33 | ``` 34 | $ hpkp-pins -file=cert.pem 35 | AD4C8VGyUrvmReK+D/PYtH52cYJrG9o7VR+uOZIh1Q0= 36 | pL1+qb9HTMRZJmuC/bB/ZI9d302BYrrqiVuRyW+DGrU= 37 | ``` 38 | 39 | And finally, how to use the `hpkp` package to verify pins as part of your application: 40 | 41 | ``` 42 | s := hpkp.NewMemStorage() 43 | 44 | s.Add("github.com", &hpkp.Header{ 45 | Permanent: true, 46 | Sha256Pins: []string{ 47 | "WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18=", 48 | "RRM1dGqnDFsCJXBTHky16vi1obOlCgFFn/yOhI/y+ho=", 49 | "k2v657xBsOVe1PQRwOsHsw3bsGT2VzIqz5K+59sNQws=", 50 | "K87oWBWM9UZfyddvDfoxL+8lpNyoUB2ptGtn0fv6G2Q=", 51 | "IQBnNBEiFuhj+8x6X8XLgh01V9Ic5/V3IRQLNFFc7v4=", 52 | "iie1VXtL7HzAMF+/PVPR9xzT80kQxdZeJ+zduCB3uj0=", 53 | "LvRiGEjRqfzurezaWuj8Wie2gyHMrW5Q06LspMnox7A=", 54 | }, 55 | }) 56 | 57 | client := &http.Client{} 58 | dialConf := &hpkp.DialerConfig{ 59 | Storage: s, 60 | PinOnly: true, 61 | TLSConfig: nil, 62 | Reporter: func(p *hpkp.PinFailure, reportUri string) { 63 | // TODO: report on PIN failure 64 | fmt.Println(p) 65 | }, 66 | } 67 | 68 | client.Transport = &http.Transport{ 69 | DialTLS: dialConf.NewDialer(), 70 | } 71 | resp, err := client.Get("https://github.com") 72 | ``` 73 | 74 | ## References 75 | 76 | * https://tools.ietf.org/html/rfc7469 77 | * https://developer.mozilla.org/en-US/docs/Web/Security/Public_Key_Pinning 78 | -------------------------------------------------------------------------------- /cmd/hpkp-headers/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | 11 | "github.com/tam7t/hpkp" 12 | ) 13 | 14 | func main() { 15 | if len(os.Args) < 2 { 16 | log.Fatal("usage: hpkp-headers ") 17 | } 18 | 19 | tr := &http.Transport{ 20 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 21 | } 22 | client := &http.Client{Transport: tr} 23 | resp, err := client.Get(os.Args[1]) 24 | if err != nil { 25 | log.Fatal(err) 26 | } 27 | 28 | h := hpkp.ParseHeader(resp) 29 | j, _ := json.Marshal(h) 30 | fmt.Println(string(j)) 31 | 32 | h = hpkp.ParseReportOnlyHeader(resp) 33 | j, _ = json.Marshal(h) 34 | fmt.Println(string(j)) 35 | } 36 | -------------------------------------------------------------------------------- /cmd/hpkp-pins/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "flag" 8 | "fmt" 9 | "io/ioutil" 10 | "log" 11 | 12 | "github.com/tam7t/hpkp" 13 | ) 14 | 15 | func main() { 16 | var err error 17 | 18 | serverPtr := flag.String("server", "", "server to inspect (ex: github.com:443)") 19 | filePtr := flag.String("file", "", "path to PEM encoded certificate") 20 | 21 | flag.Parse() 22 | 23 | if *filePtr != "" { 24 | err = fromFile(*filePtr) 25 | } 26 | 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | 31 | if *serverPtr != "" { 32 | err = fromServer(*serverPtr) 33 | } 34 | 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | } 39 | 40 | func fromServer(server string) error { 41 | conn, err := tls.Dial("tcp", server, &tls.Config{ 42 | InsecureSkipVerify: true, 43 | }) 44 | 45 | if err != nil { 46 | return err 47 | } 48 | 49 | for _, cert := range conn.ConnectionState().PeerCertificates { 50 | fmt.Println(hpkp.Fingerprint(cert)) 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func fromFile(path string) error { 57 | contents, err := ioutil.ReadFile(path) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | var block *pem.Block 63 | 64 | for len(contents) > 0 { 65 | block, contents = pem.Decode(contents) 66 | if block == nil { 67 | break 68 | } 69 | 70 | cert, err := x509.ParseCertificate(block.Bytes) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | fmt.Println(hpkp.Fingerprint(cert)) 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /dialer.go: -------------------------------------------------------------------------------- 1 | package hpkp 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "net" 7 | "strconv" 8 | ) 9 | 10 | // Storage is threadsafe hpkp storage interface 11 | type Storage interface { 12 | Lookup(host string) *Header 13 | Add(host string, d *Header) 14 | } 15 | 16 | // StorageReader is threadsafe hpkp storage interface 17 | type StorageReader interface { 18 | Lookup(host string) *Header 19 | } 20 | 21 | // PinFailureReporter callback function to keep track and report on 22 | // PIN failures 23 | type PinFailureReporter func(p *PinFailure, reportUri string) 24 | 25 | // DialerConfig describes how to verify hpkp info and report failures 26 | type DialerConfig struct { 27 | Storage StorageReader 28 | PinOnly bool 29 | TLSConfig *tls.Config 30 | Reporter PinFailureReporter 31 | } 32 | 33 | // NewDialer returns a dialer for making TLS connections with hpkp support 34 | func (c *DialerConfig) NewDialer() func(network, addr string) (net.Conn, error) { 35 | reporter := c.Reporter 36 | if reporter == nil { 37 | reporter = emptyReporter 38 | } 39 | 40 | return newPinDialer(c.Storage, reporter, c.PinOnly, c.TLSConfig) 41 | } 42 | 43 | // emptyReporter does nothing with a pin failure message 44 | var emptyReporter = func(p *PinFailure, reportUri string) { 45 | return 46 | } 47 | 48 | // newPinDialer returns a function suitable for use as DialTLS 49 | func newPinDialer(s StorageReader, r PinFailureReporter, pinOnly bool, defaultTLSConfig *tls.Config) func(network, addr string) (net.Conn, error) { 50 | return func(network, addr string) (net.Conn, error) { 51 | host, portStr, err := net.SplitHostPort(addr) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | port, err := strconv.Atoi(portStr) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | if h := s.Lookup(host); h != nil { 62 | // initial dial 63 | c, err := tls.Dial(network, addr, &tls.Config{InsecureSkipVerify: pinOnly}) 64 | if err != nil { 65 | return c, err 66 | } 67 | 68 | // intermediates can be pinned as well, loop through leaf-> root looking 69 | // for pin matches 70 | validPin := false 71 | for _, peercert := range c.ConnectionState().PeerCertificates { 72 | peerPin := Fingerprint(peercert) 73 | if h.Matches(peerPin) { 74 | validPin = true 75 | break 76 | } 77 | } 78 | // was a valid pin found? 79 | if !validPin { 80 | // notify failure callback 81 | r(NewPinFailure(host, port, h, c.ConnectionState())) 82 | return nil, errors.New("pin was not valid") 83 | } 84 | return c, nil 85 | } 86 | 87 | // do a normal dial, address isn't in hpkp cache 88 | return tls.Dial(network, addr, defaultTLSConfig) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package hpkp_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/tam7t/hpkp" 9 | ) 10 | 11 | func Example() { 12 | s := hpkp.NewMemStorage() 13 | 14 | s.Add("github.com", &hpkp.Header{ 15 | Permanent: true, 16 | Sha256Pins: []string{ 17 | "WoiWRyIOVNa9ihaBciRSC7XHjliYS9VwUGOIud4PB18=", 18 | "RRM1dGqnDFsCJXBTHky16vi1obOlCgFFn/yOhI/y+ho=", 19 | "k2v657xBsOVe1PQRwOsHsw3bsGT2VzIqz5K+59sNQws=", 20 | "K87oWBWM9UZfyddvDfoxL+8lpNyoUB2ptGtn0fv6G2Q=", 21 | "IQBnNBEiFuhj+8x6X8XLgh01V9Ic5/V3IRQLNFFc7v4=", 22 | "iie1VXtL7HzAMF+/PVPR9xzT80kQxdZeJ+zduCB3uj0=", 23 | "LvRiGEjRqfzurezaWuj8Wie2gyHMrW5Q06LspMnox7A=", 24 | }, 25 | }) 26 | 27 | client := &http.Client{} 28 | dialConf := &hpkp.DialerConfig{ 29 | Storage: s, 30 | PinOnly: true, 31 | TLSConfig: nil, 32 | Reporter: func(p *hpkp.PinFailure, reportUri string) { 33 | // TODO: report on PIN failure 34 | fmt.Println(p) 35 | }, 36 | } 37 | client.Transport = &http.Transport{ 38 | DialTLS: dialConf.NewDialer(), 39 | } 40 | 41 | resp, err := client.Get("https://github.com") 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | 46 | fmt.Println(resp.StatusCode) 47 | // Output: 200 48 | } 49 | -------------------------------------------------------------------------------- /fingerprint.go: -------------------------------------------------------------------------------- 1 | package hpkp 2 | 3 | import ( 4 | "crypto/sha256" 5 | "crypto/x509" 6 | "encoding/base64" 7 | ) 8 | 9 | // Fingerprint returns the hpkp signature of an x509 certificate 10 | func Fingerprint(c *x509.Certificate) string { 11 | digest := sha256.Sum256(c.RawSubjectPublicKeyInfo) 12 | return base64.StdEncoding.EncodeToString(digest[:]) 13 | } 14 | -------------------------------------------------------------------------------- /fingerprint_test.go: -------------------------------------------------------------------------------- 1 | package hpkp 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/pem" 6 | "testing" 7 | ) 8 | 9 | func TestFingerprint(t *testing.T) { 10 | // public github.com cert 11 | // obtained with: openssl s_client -connect github.com:443 -showcerts 12 | const certPEM = ` 13 | -----BEGIN CERTIFICATE----- 14 | MIIHeTCCBmGgAwIBAgIQC/20CQrXteZAwwsWyVKaJzANBgkqhkiG9w0BAQsFADB1 15 | MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 16 | d3cuZGlnaWNlcnQuY29tMTQwMgYDVQQDEytEaWdpQ2VydCBTSEEyIEV4dGVuZGVk 17 | IFZhbGlkYXRpb24gU2VydmVyIENBMB4XDTE2MDMxMDAwMDAwMFoXDTE4MDUxNzEy 18 | MDAwMFowgf0xHTAbBgNVBA8MFFByaXZhdGUgT3JnYW5pemF0aW9uMRMwEQYLKwYB 19 | BAGCNzwCAQMTAlVTMRkwFwYLKwYBBAGCNzwCAQITCERlbGF3YXJlMRAwDgYDVQQF 20 | Ewc1MTU3NTUwMSQwIgYDVQQJExs4OCBDb2xpbiBQIEtlbGx5LCBKciBTdHJlZXQx 21 | DjAMBgNVBBETBTk0MTA3MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5p 22 | YTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzEVMBMGA1UEChMMR2l0SHViLCBJbmMu 23 | MRMwEQYDVQQDEwpnaXRodWIuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 24 | CgKCAQEA54hc8pZclxgcupjiA/F/OZGRwm/ZlucoQGTNTKmBEgNsrn/mxhngWmPw 25 | bAvUaLP//T79Jc+1WXMpxMiz9PK6yZRRFuIo0d2bx423NA6hOL2RTtbnfs+y0PFS 26 | /YTpQSelTuq+Fuwts5v6aAweNyMcYD0HBybkkdosFoDccBNzJ92Ac8I5EVDUc3Or 27 | /4jSyZwzxu9kdmBlBzeHMvsqdH8SX9mNahXtXxRpwZnBiUjw36PgN+s9GLWGrafd 28 | 02T0ux9Yzd5ezkMxukqEAQ7AKIIijvaWPAJbK/52XLhIy2vpGNylyni/DQD18bBP 29 | T+ZG1uv0QQP9LuY/joO+FKDOTler4wIDAQABo4IDejCCA3YwHwYDVR0jBBgwFoAU 30 | PdNQpdagre7zSmAKZdMh1Pj41g8wHQYDVR0OBBYEFIhcSGcZzKB2WS0RecO+oqyH 31 | IidbMCUGA1UdEQQeMByCCmdpdGh1Yi5jb22CDnd3dy5naXRodWIuY29tMA4GA1Ud 32 | DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwdQYDVR0f 33 | BG4wbDA0oDKgMIYuaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL3NoYTItZXYtc2Vy 34 | dmVyLWcxLmNybDA0oDKgMIYuaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NoYTIt 35 | ZXYtc2VydmVyLWcxLmNybDBLBgNVHSAERDBCMDcGCWCGSAGG/WwCATAqMCgGCCsG 36 | AQUFBwIBFhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BTMAcGBWeBDAEBMIGI 37 | BggrBgEFBQcBAQR8MHowJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0 38 | LmNvbTBSBggrBgEFBQcwAoZGaHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0Rp 39 | Z2lDZXJ0U0hBMkV4dGVuZGVkVmFsaWRhdGlvblNlcnZlckNBLmNydDAMBgNVHRMB 40 | Af8EAjAAMIIBfwYKKwYBBAHWeQIEAgSCAW8EggFrAWkAdgCkuQmQtBhYFIe7E6LM 41 | Z3AKPDWYBPkb37jjd80OyA3cEAAAAVNhieoeAAAEAwBHMEUCIQCHHSEY/ROK2/sO 42 | ljbKaNEcKWz6BxHJNPOtjSyuVnSn4QIgJ6RqvYbSX1vKLeX7vpnOfCAfS2Y8lB5R 43 | NMwk6us2QiAAdgBo9pj4H2SCvjqM7rkoHUz8cVFdZ5PURNEKZ6y7T0/7xAAAAVNh 44 | iennAAAEAwBHMEUCIQDZpd5S+3to8k7lcDeWBhiJASiYTk2rNAT26lVaM3xhWwIg 45 | NUqrkIODZpRg+khhp8ag65B8mu0p4JUAmkRDbiYnRvYAdwBWFAaaL9fC7NP14b1E 46 | sj7HRna5vJkRXMDvlJhV1onQ3QAAAVNhieqZAAAEAwBIMEYCIQDnm3WStlvE99GC 47 | izSx+UGtGmQk2WTokoPgo1hfiv8zIAIhAPrYeXrBgseA9jUWWoB4IvmcZtshjXso 48 | nT8MIG1u1zF8MA0GCSqGSIb3DQEBCwUAA4IBAQCLbNtkxuspqycq8h1EpbmAX0wM 49 | 5DoW7hM/FVdz4LJ3Kmftyk1yd8j/PSxRrAQN2Mr/frKeK8NE1cMji32mJbBqpWtK 50 | /+wC+avPplBUbNpzP53cuTMF/QssxItPGNP5/OT9Aj1BxA/NofWZKh4ufV7cz3pY 51 | RDS4BF+EEFQ4l5GY+yp4WJA/xSvYsTHWeWxRD1/nl62/Rd9FN2NkacRVozCxRVle 52 | FrBHTFxqIP6kDnxiLElBrZngtY07ietaYZVLQN/ETyqLQftsf8TecwTklbjvm8NT 53 | JqbaIVifYwqwNN+4lRxS3F5lNlA/il12IOgbRioLI62o8G0DaEUQgHNf8vSG 54 | -----END CERTIFICATE-----` 55 | 56 | block, _ := pem.Decode([]byte(certPEM)) 57 | if block == nil { 58 | panic("failed to parse certificate PEM") 59 | } 60 | cert, err := x509.ParseCertificate(block.Bytes) 61 | if err != nil { 62 | panic("failed to parse certificate: " + err.Error()) 63 | } 64 | 65 | got := Fingerprint(cert) 66 | // obtained with 67 | // openssl s_client -servername github.com -connect github.com:443 \ 68 | // | openssl x509 -pubkey -noout \ 69 | // | openssl rsa -pubin -outform der \ 70 | // | openssl dgst -sha256 -binary \ 71 | // | openssl enc -base64 72 | want := `pL1+qb9HTMRZJmuC/bB/ZI9d302BYrrqiVuRyW+DGrU=` 73 | 74 | if got != want { 75 | t.Logf("want:%v", want) 76 | t.Fatalf("got:%v", got) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /header.go: -------------------------------------------------------------------------------- 1 | package hpkp 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Header holds a domain's hpkp information 11 | type Header struct { 12 | Created int64 13 | MaxAge int64 14 | IncludeSubDomains bool 15 | Permanent bool 16 | Sha256Pins []string 17 | ReportURI string 18 | } 19 | 20 | // Matches checks whether the provided pin is in the header list 21 | func (h *Header) Matches(pin string) bool { 22 | for i := range h.Sha256Pins { 23 | if h.Sha256Pins[i] == pin { 24 | return true 25 | } 26 | } 27 | return false 28 | } 29 | 30 | // ParseHeader parses the hpkp information from an http.Response. 31 | func ParseHeader(resp *http.Response) *Header { 32 | if resp == nil { 33 | return nil 34 | } 35 | 36 | // only make a header when using TLS 37 | if resp.TLS == nil { 38 | return nil 39 | } 40 | 41 | v, ok := resp.Header["Public-Key-Pins"] 42 | if !ok { 43 | return nil 44 | } 45 | 46 | // use the first header per RFC 47 | return populate(&Header{}, v[0]) 48 | } 49 | 50 | // ParseReportOnlyHeader parses the hpkp information from an http.Response. 51 | // The resulting header information should not be cached as max_age is 52 | // ignored on HPKP-RO headers per the RFC. 53 | func ParseReportOnlyHeader(resp *http.Response) *Header { 54 | if resp == nil { 55 | return nil 56 | } 57 | 58 | // only make a header when using TLS 59 | if resp.TLS == nil { 60 | return nil 61 | } 62 | 63 | v, ok := resp.Header["Public-Key-Pins-Report-Only"] 64 | if !ok { 65 | return nil 66 | } 67 | 68 | // use the first header per RFC 69 | return populate(&Header{}, v[0]) 70 | } 71 | 72 | func populate(h *Header, v string) *Header { 73 | h.Sha256Pins = []string{} 74 | 75 | for _, field := range strings.Split(v, ";") { 76 | field = strings.TrimSpace(field) 77 | 78 | i := strings.Index(field, "pin-sha256") 79 | if i >= 0 { 80 | h.Sha256Pins = append(h.Sha256Pins, field[i+12:len(field)-1]) 81 | continue 82 | } 83 | 84 | i = strings.Index(field, "report-uri") 85 | if i >= 0 { 86 | h.ReportURI = field[i+12 : len(field)-1] 87 | continue 88 | } 89 | 90 | i = strings.Index(field, "max-age=") 91 | if i >= 0 { 92 | ma, err := strconv.Atoi(field[i+8:]) 93 | if err == nil { 94 | h.MaxAge = int64(ma) 95 | } 96 | continue 97 | } 98 | 99 | if strings.Contains(field, "includeSubDomains") { 100 | h.IncludeSubDomains = true 101 | continue 102 | } 103 | } 104 | 105 | h.Created = time.Now().Unix() 106 | return h 107 | } 108 | -------------------------------------------------------------------------------- /header_test.go: -------------------------------------------------------------------------------- 1 | package hpkp 2 | 3 | import ( 4 | "crypto/tls" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func TestHeader_Matches(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | header *Header 13 | pin string 14 | expected bool 15 | }{ 16 | { 17 | name: "no match", 18 | header: &Header{}, 19 | pin: "d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=", 20 | expected: false, 21 | }, 22 | { 23 | name: "match", 24 | header: &Header{ 25 | Sha256Pins: []string{ 26 | "d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=", 27 | "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=", 28 | }, 29 | }, 30 | pin: "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=", 31 | expected: true, 32 | }, 33 | } 34 | 35 | for _, test := range tests { 36 | out := test.header.Matches(test.pin) 37 | if out != test.expected { 38 | t.Logf("want:%v", test.expected) 39 | t.Logf("got:%v", out) 40 | t.Fatalf("test case failed: %s", test.name) 41 | } 42 | } 43 | } 44 | 45 | func TestParseHeader(t *testing.T) { 46 | tests := []struct { 47 | name string 48 | response *http.Response 49 | expected *Header 50 | }{ 51 | { 52 | name: "nil everything", 53 | response: nil, 54 | expected: nil, 55 | }, 56 | { 57 | name: "no header", 58 | response: &http.Response{ 59 | StatusCode: 200, 60 | }, 61 | expected: nil, 62 | }, 63 | { 64 | name: "hpkp header, but over http", 65 | response: &http.Response{ 66 | StatusCode: 200, 67 | Header: map[string][]string{ 68 | "Public-Key-Pins": []string{`max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`}, 69 | }, 70 | }, 71 | expected: nil, 72 | }, 73 | { 74 | name: "multiple headers", 75 | response: &http.Response{ 76 | StatusCode: 200, 77 | Header: map[string][]string{ 78 | "Public-Key-Pins": []string{ 79 | `max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`, 80 | `max-age=3001; pin-sha256="bad header"`, 81 | }, 82 | }, 83 | TLS: &tls.ConnectionState{}, 84 | }, 85 | expected: &Header{ 86 | MaxAge: 3000, 87 | IncludeSubDomains: false, 88 | Permanent: false, 89 | Sha256Pins: []string{ 90 | "d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=", 91 | "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=", 92 | }, 93 | }, 94 | }, 95 | // https://tools.ietf.org/html/rfc7469#section-2.1.5 96 | { 97 | name: "hpkp header (1)", 98 | response: &http.Response{ 99 | StatusCode: 200, 100 | Header: map[string][]string{ 101 | "Public-Key-Pins": []string{`max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`}, 102 | }, 103 | TLS: &tls.ConnectionState{}, 104 | }, 105 | expected: &Header{ 106 | MaxAge: 3000, 107 | IncludeSubDomains: false, 108 | Permanent: false, 109 | Sha256Pins: []string{ 110 | "d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=", 111 | "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=", 112 | }, 113 | }, 114 | }, 115 | { 116 | name: "hpkp header (2)", 117 | response: &http.Response{ 118 | StatusCode: 200, 119 | Header: map[string][]string{ 120 | "Public-Key-Pins": []string{`max-age=2592000; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="; pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="`}, 121 | }, 122 | TLS: &tls.ConnectionState{}, 123 | }, 124 | expected: &Header{ 125 | MaxAge: 2592000, 126 | IncludeSubDomains: false, 127 | Permanent: false, 128 | Sha256Pins: []string{ 129 | "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=", 130 | "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=", 131 | }, 132 | }, 133 | }, 134 | { 135 | name: "hpkp header (3)", 136 | response: &http.Response{ 137 | StatusCode: 200, 138 | Header: map[string][]string{ 139 | "Public-Key-Pins": []string{`max-age=2592000; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="; pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="; report-uri="http://example.com/pkp-report"`}, 140 | }, 141 | TLS: &tls.ConnectionState{}, 142 | }, 143 | expected: &Header{ 144 | MaxAge: 2592000, 145 | IncludeSubDomains: false, 146 | Permanent: false, 147 | Sha256Pins: []string{ 148 | "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=", 149 | "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=", 150 | }, 151 | ReportURI: "http://example.com/pkp-report", 152 | }, 153 | }, 154 | { 155 | name: "hpkp header (4)", 156 | response: &http.Response{ 157 | StatusCode: 200, 158 | Header: map[string][]string{ 159 | "Public-Key-Pins-Report-Only": []string{`max-age=2592000; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="; pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="; report-uri="http://example.com/pkp-report"`}, 160 | }, 161 | TLS: &tls.ConnectionState{}, 162 | }, 163 | expected: nil, 164 | }, 165 | { 166 | name: "hpkp header (5)", 167 | response: &http.Response{ 168 | StatusCode: 200, 169 | Header: map[string][]string{ 170 | "Public-Key-Pins": []string{`pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="; max-age=259200`}, 171 | }, 172 | TLS: &tls.ConnectionState{}, 173 | }, 174 | expected: &Header{ 175 | MaxAge: 259200, 176 | IncludeSubDomains: false, 177 | Permanent: false, 178 | Sha256Pins: []string{ 179 | "d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=", 180 | "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=", 181 | }, 182 | }, 183 | }, 184 | { 185 | name: "hpkp header (6)", 186 | response: &http.Response{ 187 | StatusCode: 200, 188 | Header: map[string][]string{ 189 | "Public-Key-Pins": []string{`pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="; pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="; max-age=10000; includeSubDomains`}, 190 | }, 191 | TLS: &tls.ConnectionState{}, 192 | }, 193 | expected: &Header{ 194 | MaxAge: 10000, 195 | IncludeSubDomains: true, 196 | Permanent: false, 197 | Sha256Pins: []string{ 198 | "d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=", 199 | "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=", 200 | "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=", 201 | }, 202 | }, 203 | }, 204 | } 205 | 206 | for _, test := range tests { 207 | out := ParseHeader(test.response) 208 | if !equalHeaders(out, test.expected) { 209 | t.Logf("want:%v", test.expected) 210 | t.Logf("got:%v", out) 211 | t.Fatalf("test case failed: %s", test.name) 212 | } 213 | } 214 | } 215 | 216 | func TestParseReportOnlyHeader(t *testing.T) { 217 | tests := []struct { 218 | name string 219 | response *http.Response 220 | expected *Header 221 | }{ 222 | { 223 | name: "nil everything", 224 | response: nil, 225 | expected: nil, 226 | }, 227 | { 228 | name: "no header", 229 | response: &http.Response{ 230 | StatusCode: 200, 231 | }, 232 | expected: nil, 233 | }, 234 | { 235 | name: "hpkp header, but over http", 236 | response: &http.Response{ 237 | StatusCode: 200, 238 | Header: map[string][]string{ 239 | "Public-Key-Pins": []string{`max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`}, 240 | }, 241 | }, 242 | expected: nil, 243 | }, 244 | { 245 | name: "multiple headers", 246 | response: &http.Response{ 247 | StatusCode: 200, 248 | Header: map[string][]string{ 249 | "Public-Key-Pins-Report-Only": []string{ 250 | `max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`, 251 | `max-age=3001; pin-sha256="bad header"`, 252 | }, 253 | }, 254 | TLS: &tls.ConnectionState{}, 255 | }, 256 | expected: &Header{ 257 | MaxAge: 3000, 258 | IncludeSubDomains: false, 259 | Permanent: false, 260 | Sha256Pins: []string{ 261 | "d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=", 262 | "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=", 263 | }, 264 | }, 265 | }, 266 | // https://tools.ietf.org/html/rfc7469#section-2.1.5 267 | { 268 | name: "hpkp header (1)", 269 | response: &http.Response{ 270 | StatusCode: 200, 271 | Header: map[string][]string{ 272 | "Public-Key-Pins": []string{`max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`}, 273 | }, 274 | TLS: &tls.ConnectionState{}, 275 | }, 276 | expected: nil, 277 | }, 278 | { 279 | name: "hpkp header (4)", 280 | response: &http.Response{ 281 | StatusCode: 200, 282 | Header: map[string][]string{ 283 | "Public-Key-Pins-Report-Only": []string{`max-age=2592000; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="; pin-sha256="LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="; report-uri="http://example.com/pkp-report"`}, 284 | }, 285 | TLS: &tls.ConnectionState{}, 286 | }, 287 | expected: &Header{ 288 | MaxAge: 2592000, 289 | IncludeSubDomains: false, 290 | Permanent: false, 291 | Sha256Pins: []string{ 292 | "E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=", 293 | "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=", 294 | }, 295 | ReportURI: "http://example.com/pkp-report", 296 | }, 297 | }, 298 | } 299 | 300 | for _, test := range tests { 301 | out := ParseReportOnlyHeader(test.response) 302 | if !equalHeaders(out, test.expected) { 303 | t.Logf("want:%v", test.expected) 304 | t.Logf("got:%v", out) 305 | t.Fatalf("test case failed: %s", test.name) 306 | } 307 | } 308 | } 309 | 310 | func equalHeaders(a, b *Header) bool { 311 | if a == nil && b == nil { 312 | return true 313 | } 314 | 315 | if a == nil || b == nil { 316 | return false 317 | } 318 | 319 | if a.IncludeSubDomains != b.IncludeSubDomains { 320 | return false 321 | } 322 | 323 | if a.MaxAge != b.MaxAge { 324 | return false 325 | } 326 | 327 | if a.Permanent != b.Permanent { 328 | return false 329 | } 330 | 331 | if a.ReportURI != b.ReportURI { 332 | return false 333 | } 334 | 335 | if len(a.Sha256Pins) != len(b.Sha256Pins) { 336 | return false 337 | } 338 | 339 | for i := range a.Sha256Pins { 340 | if a.Sha256Pins[i] != b.Sha256Pins[i] { 341 | return false 342 | } 343 | } 344 | 345 | return true 346 | } 347 | -------------------------------------------------------------------------------- /report.go: -------------------------------------------------------------------------------- 1 | package hpkp 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "time" 9 | ) 10 | 11 | // PinFailure hold fields required for POSTing a pin validation failure JSON message 12 | // to a host's report-uri. 13 | type PinFailure struct { 14 | DateTime string `json:"date-time"` 15 | Hostname string `json:"hostname"` 16 | Port int `json:"port"` 17 | EffectiveExpirationDate string `json:"effective-expiration-date"` 18 | IncludeSubdomains bool `json:"include-subdomains"` 19 | NotedHostname string `json:"noted-hostname"` 20 | ServedCertificateChain []string `json:"served-certificate-chain"` 21 | ValidatedCertificateChain []string `json:"validated-certificate-chain"` 22 | KnownPins []string `json:"known-pins"` 23 | } 24 | 25 | // NewPinFailure creates a struct to report information on failed hpkp connections 26 | func NewPinFailure(host string, port int, h *Header, c tls.ConnectionState) (*PinFailure, string) { 27 | if h == nil { 28 | return nil, "" 29 | } 30 | 31 | verifiedChain := []*x509.Certificate{} 32 | if len(c.VerifiedChains) > 0 { 33 | verifiedChain = c.VerifiedChains[len(c.VerifiedChains)-1] 34 | } 35 | 36 | return &PinFailure{ 37 | DateTime: time.Now().Format(time.RFC3339), 38 | Hostname: host, 39 | Port: port, 40 | EffectiveExpirationDate: time.Unix(h.Created+h.MaxAge, 0).UTC().Format(time.RFC3339), 41 | IncludeSubdomains: h.IncludeSubDomains, 42 | NotedHostname: c.ServerName, 43 | ServedCertificateChain: encodeCertificatesPEM(c.PeerCertificates), 44 | ValidatedCertificateChain: encodeCertificatesPEM(verifiedChain), 45 | KnownPins: h.Sha256Pins, 46 | }, h.ReportURI 47 | } 48 | 49 | // encodeCertificatesPEM converts a slice of x509 certficates to a slice of PEM encoded strings 50 | func encodeCertificatesPEM(certs []*x509.Certificate) []string { 51 | var pemCerts []string 52 | 53 | var buffer bytes.Buffer 54 | for _, cert := range certs { 55 | pem.Encode(&buffer, &pem.Block{ 56 | Type: "CERTIFICATE", 57 | Bytes: cert.Raw, 58 | }) 59 | pemCerts = append(pemCerts, string(buffer.Bytes())) 60 | buffer.Reset() 61 | } 62 | 63 | return pemCerts 64 | } 65 | -------------------------------------------------------------------------------- /report_test.go: -------------------------------------------------------------------------------- 1 | package hpkp 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | var certs = []string{ 12 | `-----BEGIN CERTIFICATE----- 13 | MIIHeTCCBmGgAwIBAgIQC/20CQrXteZAwwsWyVKaJzANBgkqhkiG9w0BAQsFADB1 14 | MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 15 | d3cuZGlnaWNlcnQuY29tMTQwMgYDVQQDEytEaWdpQ2VydCBTSEEyIEV4dGVuZGVk 16 | IFZhbGlkYXRpb24gU2VydmVyIENBMB4XDTE2MDMxMDAwMDAwMFoXDTE4MDUxNzEy 17 | MDAwMFowgf0xHTAbBgNVBA8MFFByaXZhdGUgT3JnYW5pemF0aW9uMRMwEQYLKwYB 18 | BAGCNzwCAQMTAlVTMRkwFwYLKwYBBAGCNzwCAQITCERlbGF3YXJlMRAwDgYDVQQF 19 | Ewc1MTU3NTUwMSQwIgYDVQQJExs4OCBDb2xpbiBQIEtlbGx5LCBKciBTdHJlZXQx 20 | DjAMBgNVBBETBTk0MTA3MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5p 21 | YTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzEVMBMGA1UEChMMR2l0SHViLCBJbmMu 22 | MRMwEQYDVQQDEwpnaXRodWIuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 23 | CgKCAQEA54hc8pZclxgcupjiA/F/OZGRwm/ZlucoQGTNTKmBEgNsrn/mxhngWmPw 24 | bAvUaLP//T79Jc+1WXMpxMiz9PK6yZRRFuIo0d2bx423NA6hOL2RTtbnfs+y0PFS 25 | /YTpQSelTuq+Fuwts5v6aAweNyMcYD0HBybkkdosFoDccBNzJ92Ac8I5EVDUc3Or 26 | /4jSyZwzxu9kdmBlBzeHMvsqdH8SX9mNahXtXxRpwZnBiUjw36PgN+s9GLWGrafd 27 | 02T0ux9Yzd5ezkMxukqEAQ7AKIIijvaWPAJbK/52XLhIy2vpGNylyni/DQD18bBP 28 | T+ZG1uv0QQP9LuY/joO+FKDOTler4wIDAQABo4IDejCCA3YwHwYDVR0jBBgwFoAU 29 | PdNQpdagre7zSmAKZdMh1Pj41g8wHQYDVR0OBBYEFIhcSGcZzKB2WS0RecO+oqyH 30 | IidbMCUGA1UdEQQeMByCCmdpdGh1Yi5jb22CDnd3dy5naXRodWIuY29tMA4GA1Ud 31 | DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwdQYDVR0f 32 | BG4wbDA0oDKgMIYuaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL3NoYTItZXYtc2Vy 33 | dmVyLWcxLmNybDA0oDKgMIYuaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NoYTIt 34 | ZXYtc2VydmVyLWcxLmNybDBLBgNVHSAERDBCMDcGCWCGSAGG/WwCATAqMCgGCCsG 35 | AQUFBwIBFhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BTMAcGBWeBDAEBMIGI 36 | BggrBgEFBQcBAQR8MHowJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0 37 | LmNvbTBSBggrBgEFBQcwAoZGaHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0Rp 38 | Z2lDZXJ0U0hBMkV4dGVuZGVkVmFsaWRhdGlvblNlcnZlckNBLmNydDAMBgNVHRMB 39 | Af8EAjAAMIIBfwYKKwYBBAHWeQIEAgSCAW8EggFrAWkAdgCkuQmQtBhYFIe7E6LM 40 | Z3AKPDWYBPkb37jjd80OyA3cEAAAAVNhieoeAAAEAwBHMEUCIQCHHSEY/ROK2/sO 41 | ljbKaNEcKWz6BxHJNPOtjSyuVnSn4QIgJ6RqvYbSX1vKLeX7vpnOfCAfS2Y8lB5R 42 | NMwk6us2QiAAdgBo9pj4H2SCvjqM7rkoHUz8cVFdZ5PURNEKZ6y7T0/7xAAAAVNh 43 | iennAAAEAwBHMEUCIQDZpd5S+3to8k7lcDeWBhiJASiYTk2rNAT26lVaM3xhWwIg 44 | NUqrkIODZpRg+khhp8ag65B8mu0p4JUAmkRDbiYnRvYAdwBWFAaaL9fC7NP14b1E 45 | sj7HRna5vJkRXMDvlJhV1onQ3QAAAVNhieqZAAAEAwBIMEYCIQDnm3WStlvE99GC 46 | izSx+UGtGmQk2WTokoPgo1hfiv8zIAIhAPrYeXrBgseA9jUWWoB4IvmcZtshjXso 47 | nT8MIG1u1zF8MA0GCSqGSIb3DQEBCwUAA4IBAQCLbNtkxuspqycq8h1EpbmAX0wM 48 | 5DoW7hM/FVdz4LJ3Kmftyk1yd8j/PSxRrAQN2Mr/frKeK8NE1cMji32mJbBqpWtK 49 | /+wC+avPplBUbNpzP53cuTMF/QssxItPGNP5/OT9Aj1BxA/NofWZKh4ufV7cz3pY 50 | RDS4BF+EEFQ4l5GY+yp4WJA/xSvYsTHWeWxRD1/nl62/Rd9FN2NkacRVozCxRVle 51 | FrBHTFxqIP6kDnxiLElBrZngtY07ietaYZVLQN/ETyqLQftsf8TecwTklbjvm8NT 52 | JqbaIVifYwqwNN+4lRxS3F5lNlA/il12IOgbRioLI62o8G0DaEUQgHNf8vSG 53 | -----END CERTIFICATE----- 54 | `, 55 | `-----BEGIN CERTIFICATE----- 56 | MIIEtjCCA56gAwIBAgIQDHmpRLCMEZUgkmFf4msdgzANBgkqhkiG9w0BAQsFADBs 57 | MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 58 | d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j 59 | ZSBFViBSb290IENBMB4XDTEzMTAyMjEyMDAwMFoXDTI4MTAyMjEyMDAwMFowdTEL 60 | MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 61 | LmRpZ2ljZXJ0LmNvbTE0MDIGA1UEAxMrRGlnaUNlcnQgU0hBMiBFeHRlbmRlZCBW 62 | YWxpZGF0aW9uIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 63 | ggEBANdTpARR+JmmFkhLZyeqk0nQOe0MsLAAh/FnKIaFjI5j2ryxQDji0/XspQUY 64 | uD0+xZkXMuwYjPrxDKZkIYXLBxA0sFKIKx9om9KxjxKws9LniB8f7zh3VFNfgHk/ 65 | LhqqqB5LKw2rt2O5Nbd9FLxZS99RStKh4gzikIKHaq7q12TWmFXo/a8aUGxUvBHy 66 | /Urynbt/DvTVvo4WiRJV2MBxNO723C3sxIclho3YIeSwTQyJ3DkmF93215SF2AQh 67 | cJ1vb/9cuhnhRctWVyh+HA1BV6q3uCe7seT6Ku8hI3UarS2bhjWMnHe1c63YlC3k 68 | 8wyd7sFOYn4XwHGeLN7x+RAoGTMCAwEAAaOCAUkwggFFMBIGA1UdEwEB/wQIMAYB 69 | Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEF 70 | BQcDAjA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp 71 | Z2ljZXJ0LmNvbTBLBgNVHR8ERDBCMECgPqA8hjpodHRwOi8vY3JsNC5kaWdpY2Vy 72 | dC5jb20vRGlnaUNlcnRIaWdoQXNzdXJhbmNlRVZSb290Q0EuY3JsMD0GA1UdIAQ2 73 | MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIBFhxodHRwczovL3d3dy5kaWdpY2VydC5j 74 | b20vQ1BTMB0GA1UdDgQWBBQ901Cl1qCt7vNKYApl0yHU+PjWDzAfBgNVHSMEGDAW 75 | gBSxPsNpA/i/RwHUmCYaCALvY2QrwzANBgkqhkiG9w0BAQsFAAOCAQEAnbbQkIbh 76 | hgLtxaDwNBx0wY12zIYKqPBKikLWP8ipTa18CK3mtlC4ohpNiAexKSHc59rGPCHg 77 | 4xFJcKx6HQGkyhE6V6t9VypAdP3THYUYUN9XR3WhfVUgLkc3UHKMf4Ib0mKPLQNa 78 | 2sPIoc4sUqIAY+tzunHISScjl2SFnjgOrWNoPLpSgVh5oywM395t6zHyuqB8bPEs 79 | 1OG9d4Q3A84ytciagRpKkk47RpqF/oOi+Z6Mo8wNXrM9zwR4jxQUezKcxwCmXMS1 80 | oVWNWlZopCJwqjyBcdmdqEU79OX2olHdx3ti6G8MdOu42vi/hw15UJGQmxg7kVkn 81 | 8TUoE6smftX3eg== 82 | -----END CERTIFICATE----- 83 | `, 84 | `-----BEGIN CERTIFICATE----- 85 | MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs 86 | MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 87 | d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j 88 | ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL 89 | MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 90 | LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug 91 | RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm 92 | +9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW 93 | PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM 94 | xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB 95 | Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3 96 | hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg 97 | EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF 98 | MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA 99 | FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec 100 | nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z 101 | eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF 102 | hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2 103 | Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe 104 | vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep 105 | +OkuE6N36B9K 106 | -----END CERTIFICATE----- 107 | `, 108 | } 109 | 110 | func peerCerts(c []string) []*x509.Certificate { 111 | out := []*x509.Certificate{} 112 | for i := range c { 113 | block, _ := pem.Decode([]byte(certs[i])) 114 | if block == nil { 115 | panic("failed to parse certificate PEM") 116 | } 117 | cert, err := x509.ParseCertificate(block.Bytes) 118 | if err != nil { 119 | panic("failed to parse certificate: " + err.Error()) 120 | } 121 | out = append(out, cert) 122 | } 123 | return out 124 | } 125 | 126 | func TestNewPinFailure(t *testing.T) { 127 | tests := []struct { 128 | name string 129 | host string 130 | port int 131 | header *Header 132 | connState tls.ConnectionState 133 | expectedReport *PinFailure 134 | expectedURI string 135 | }{ 136 | { 137 | name: "nil test", 138 | }, 139 | { 140 | name: "basic", 141 | host: "github.com", 142 | port: 443, 143 | header: &Header{ 144 | Permanent: true, 145 | IncludeSubDomains: false, 146 | Sha256Pins: []string{ 147 | "LvRiGEjRqfzurezaWuj8Wie2gyHMrW5Q06LspMnox7A=", 148 | }, 149 | }, 150 | connState: tls.ConnectionState{ 151 | ServerName: "github.com", 152 | PeerCertificates: peerCerts(certs[:1]), 153 | VerifiedChains: [][]*x509.Certificate{peerCerts(certs)}, 154 | }, 155 | expectedReport: &PinFailure{ 156 | Hostname: "github.com", 157 | Port: 443, 158 | IncludeSubdomains: false, 159 | NotedHostname: "github.com", 160 | KnownPins: []string{ 161 | "LvRiGEjRqfzurezaWuj8Wie2gyHMrW5Q06LspMnox7A=", 162 | }, 163 | EffectiveExpirationDate: "1970-01-01T00:00:00Z", 164 | ServedCertificateChain: certs[:1], 165 | ValidatedCertificateChain: certs, 166 | }, 167 | expectedURI: "", 168 | }, 169 | } 170 | 171 | for _, test := range tests { 172 | pf, uri := NewPinFailure(test.host, test.port, test.header, test.connState) 173 | 174 | if uri != test.expectedURI { 175 | t.Logf("want:%v", test.expectedURI) 176 | t.Logf("got:%v", uri) 177 | t.Fatalf("test case failed: %s", test.name) 178 | } 179 | 180 | if !equalFailures(pf, test.expectedReport) { 181 | t.Logf("want:%v", test.expectedReport) 182 | t.Logf("got:%v", pf) 183 | t.Fatalf("test case failed: %s", test.name) 184 | } 185 | } 186 | } 187 | 188 | func equalFailures(a, b *PinFailure) bool { 189 | if a == nil && b == nil { 190 | return true 191 | } 192 | 193 | if a == nil || b == nil { 194 | return false 195 | } 196 | 197 | if a.Port != b.Port { 198 | return false 199 | } 200 | 201 | if a.Hostname != b.Hostname { 202 | return false 203 | } 204 | 205 | if !reflect.DeepEqual(a.KnownPins, b.KnownPins) { 206 | return false 207 | } 208 | 209 | if a.NotedHostname != b.NotedHostname { 210 | return false 211 | } 212 | 213 | if a.IncludeSubdomains != b.IncludeSubdomains { 214 | return false 215 | } 216 | 217 | if !reflect.DeepEqual(a.ServedCertificateChain, b.ServedCertificateChain) { 218 | return false 219 | } 220 | 221 | if a.EffectiveExpirationDate != b.EffectiveExpirationDate { 222 | return false 223 | } 224 | 225 | if !reflect.DeepEqual(a.ValidatedCertificateChain, b.ValidatedCertificateChain) { 226 | return false 227 | } 228 | 229 | return true 230 | } 231 | -------------------------------------------------------------------------------- /storage.go: -------------------------------------------------------------------------------- 1 | package hpkp 2 | 3 | import ( 4 | "strings" 5 | "sync" 6 | ) 7 | 8 | // MemStorage is threadsafe hpkp host storage backed by an in-memory map 9 | type MemStorage struct { 10 | domains map[string]Header 11 | mutex sync.Mutex 12 | } 13 | 14 | // NewMemStorage initializes hpkp in-memory datastructure 15 | func NewMemStorage() *MemStorage { 16 | m := &MemStorage{} 17 | m.domains = make(map[string]Header) 18 | return m 19 | } 20 | 21 | // Lookup returns the corresponding hpkp header information for a given host 22 | func (s *MemStorage) Lookup(host string) *Header { 23 | s.mutex.Lock() 24 | defer s.mutex.Unlock() 25 | 26 | d, ok := s.domains[host] 27 | if ok { 28 | return copy(d) 29 | } 30 | 31 | // is h a subdomain of an hpkp domain, walk the domain to see if it is a sub 32 | // sub ... sub domain of a domain that has the `includeSubDomains` rule 33 | l := len(host) 34 | for l > 0 { 35 | i := strings.Index(host, ".") 36 | if i > 0 { 37 | host = host[i+1:] 38 | d, ok := s.domains[host] 39 | if ok { 40 | if d.IncludeSubDomains { 41 | return copy(d) 42 | } 43 | } 44 | l = len(host) 45 | } else { 46 | break 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func copy(h Header) *Header { 54 | d := h 55 | return &d 56 | } 57 | 58 | // Add a domain to hpkp storage 59 | func (s *MemStorage) Add(host string, d *Header) { 60 | s.mutex.Lock() 61 | defer s.mutex.Unlock() 62 | 63 | if s.domains == nil { 64 | s.domains = make(map[string]Header) 65 | } 66 | 67 | if d.MaxAge == 0 && !d.Permanent { 68 | check, ok := s.domains[host] 69 | if ok { 70 | if !check.Permanent { 71 | delete(s.domains, host) 72 | } 73 | } 74 | } else { 75 | s.domains[host] = *d 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /storage_test.go: -------------------------------------------------------------------------------- 1 | package hpkp 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var createdAt = time.Now().Unix() 11 | 12 | func TestMemStorage_Lookup(t *testing.T) { 13 | m := NewMemStorage() 14 | m.Add("example.org", &Header{ 15 | IncludeSubDomains: false, 16 | Permanent: false, 17 | Created: createdAt, 18 | MaxAge: 100, 19 | }) 20 | m.Add("a.example.org", &Header{ 21 | IncludeSubDomains: true, 22 | Permanent: false, 23 | Created: createdAt, 24 | MaxAge: 100, 25 | }) 26 | m.Add("a.example.com", &Header{ 27 | IncludeSubDomains: true, 28 | Permanent: false, 29 | Created: createdAt, 30 | MaxAge: 100, 31 | }) 32 | m.Add("b.example.com", &Header{ 33 | IncludeSubDomains: false, 34 | Permanent: false, 35 | Created: createdAt, 36 | MaxAge: 100, 37 | }) 38 | 39 | done := make(chan bool) 40 | 41 | var orgErr error 42 | go func() { 43 | orgErr = orgTest(m, t) 44 | // try to make a data race 45 | m.Add("example.org", &Header{ 46 | IncludeSubDomains: false, 47 | Permanent: false, 48 | Created: createdAt, 49 | MaxAge: 100, 50 | }) 51 | done <- true 52 | }() 53 | 54 | var comErr error 55 | go func() { 56 | comErr = comTest(m, t) 57 | // try to make a data race 58 | m.Add("a.example.com", &Header{ 59 | IncludeSubDomains: true, 60 | Permanent: false, 61 | Created: createdAt, 62 | MaxAge: 100, 63 | }) 64 | done <- true 65 | }() 66 | 67 | // wait for tests to finish 68 | <-done 69 | <-done 70 | 71 | if orgErr != nil { 72 | t.Fatal(orgErr) 73 | } 74 | 75 | if comErr != nil { 76 | t.Fatal(comErr) 77 | } 78 | } 79 | 80 | func orgTest(m Storage, t *testing.T) error { 81 | tests := []struct { 82 | name string 83 | host string 84 | expected *Header 85 | }{ 86 | { 87 | name: "root match org", 88 | host: "example.org", 89 | expected: &Header{ 90 | IncludeSubDomains: false, 91 | Permanent: false, 92 | Created: createdAt, 93 | MaxAge: 100, 94 | }, 95 | }, 96 | { 97 | name: "subdomain match org", 98 | host: "a.example.org", 99 | expected: &Header{ 100 | IncludeSubDomains: true, 101 | Permanent: false, 102 | Created: createdAt, 103 | MaxAge: 100, 104 | }, 105 | }, 106 | { 107 | name: "subdomain miss-match org", 108 | host: "b.example.org", 109 | expected: nil, 110 | }, 111 | } 112 | 113 | for _, test := range tests { 114 | out := m.Lookup(test.host) 115 | if !reflect.DeepEqual(out, test.expected) { 116 | t.Logf("host: %s", test.host) 117 | t.Logf("want:%v", test.expected) 118 | t.Logf("got:%v", out) 119 | return fmt.Errorf("test case failed: %s", test.name) 120 | } 121 | } 122 | return nil 123 | } 124 | 125 | func comTest(m Storage, t *testing.T) error { 126 | tests := []struct { 127 | name string 128 | host string 129 | expected *Header 130 | }{ 131 | { 132 | name: "subdomain enabled", 133 | host: "z.a.example.com", 134 | expected: &Header{ 135 | IncludeSubDomains: true, 136 | Permanent: false, 137 | Created: createdAt, 138 | MaxAge: 100, 139 | }, 140 | }, 141 | { 142 | name: "sub-subdomain", 143 | host: "z.y.a.example.com", 144 | expected: &Header{ 145 | IncludeSubDomains: true, 146 | Permanent: false, 147 | Created: createdAt, 148 | MaxAge: 100, 149 | }, 150 | }, 151 | { 152 | name: "subdomain disabled", 153 | host: "z.b.example.com", 154 | expected: nil, 155 | }, 156 | { 157 | name: "exact match", 158 | host: "b.example.com", 159 | expected: &Header{ 160 | IncludeSubDomains: false, 161 | Permanent: false, 162 | Created: createdAt, 163 | MaxAge: 100, 164 | }, 165 | }, 166 | { 167 | name: "complete missmatch", 168 | host: "z.example.com", 169 | expected: nil, 170 | }, 171 | } 172 | 173 | for _, test := range tests { 174 | out := m.Lookup(test.host) 175 | if !reflect.DeepEqual(out, test.expected) { 176 | t.Logf("host: %s", test.host) 177 | t.Logf("want:%v", test.expected) 178 | t.Logf("got:%v", out) 179 | return fmt.Errorf("test case failed: %s", test.name) 180 | } 181 | } 182 | return nil 183 | } 184 | 185 | func TestMemStorage_Add(t *testing.T) { 186 | m := &MemStorage{} 187 | 188 | // permanent 189 | permanentDomain := Header{ 190 | IncludeSubDomains: false, 191 | Permanent: true, 192 | Created: time.Now().Unix(), 193 | MaxAge: 0, 194 | } 195 | 196 | m.Add("example.org", &permanentDomain) 197 | 198 | expected := map[string]Header{ 199 | "example.org": permanentDomain, 200 | } 201 | 202 | if !reflect.DeepEqual(m.domains, expected) { 203 | t.Logf("want:%v", expected) 204 | t.Logf("got:%v", m.domains) 205 | t.Fatal("Add failed after permanent") 206 | } 207 | 208 | // normal 209 | normalDomain := Header{ 210 | IncludeSubDomains: false, 211 | Permanent: false, 212 | Created: time.Now().Unix(), 213 | MaxAge: 100, 214 | } 215 | 216 | m.Add("a.example.org", &normalDomain) 217 | 218 | expected = map[string]Header{ 219 | "example.org": permanentDomain, 220 | "a.example.org": normalDomain, 221 | } 222 | 223 | if !reflect.DeepEqual(m.domains, expected) { 224 | t.Logf("want:%v", expected) 225 | t.Logf("got:%v", m.domains) 226 | t.Fatal("Add failed after adding normal") 227 | } 228 | 229 | // remove normal 230 | removeNormalDomain := Header{ 231 | IncludeSubDomains: false, 232 | Permanent: false, 233 | Created: time.Now().Unix(), 234 | MaxAge: 0, 235 | } 236 | 237 | m.Add("a.example.org", &removeNormalDomain) 238 | 239 | expected = map[string]Header{ 240 | "example.org": permanentDomain, 241 | } 242 | 243 | if !reflect.DeepEqual(m.domains, expected) { 244 | t.Logf("want:%v", expected) 245 | t.Logf("got:%v", m.domains) 246 | t.Fatal("Add failed after removing normal") 247 | } 248 | 249 | // attempt to remove the permanent 250 | removePermanetDomain := Header{ 251 | IncludeSubDomains: false, 252 | Permanent: false, 253 | Created: time.Now().Unix(), 254 | MaxAge: 0, 255 | } 256 | 257 | m.Add("example.org", &removePermanetDomain) 258 | 259 | expected = map[string]Header{ 260 | "example.org": permanentDomain, 261 | } 262 | 263 | if !reflect.DeepEqual(m.domains, expected) { 264 | t.Logf("want:%v", expected) 265 | t.Logf("got:%v", m.domains) 266 | t.Fatal("Add failed after attempting to remove the permanent domain") 267 | } 268 | } 269 | --------------------------------------------------------------------------------