Browse Source

report-uri: new dialer iface & header parser for pin failure reports

Tommy Murphy 3 years ago
parent
commit
6098c6cdd3
9 changed files with 537 additions and 31 deletions
  1. 11 1
      README.md
  2. 4 0
      cmd/hpkp-headers/main.go
  3. 50 17
      dialer.go
  4. 13 2
      example_test.go
  5. 40 8
      header.go
  6. 124 2
      header_test.go
  7. 64 0
      report.go
  8. 230 0
      report_test.go
  9. 1 1
      storage.go

+ 11 - 1
README.md

@@ -55,8 +55,18 @@ s.Add("github.com", &hpkp.Header{
 })
 
 client := &http.Client{}
+dialConf := &hpkp.DialerConfig{
+	Storage:   s,
+	PinOnly:   true,
+	TLSConfig: nil,
+	Reporter: func(p *hpkp.PinFailure, reportUri string) {
+		// TODO: report on PIN failure
+		fmt.Println(p)
+	},
+}
+
 client.Transport = &http.Transport{
-    DialTLS: hpkp.PinOnlyDialer(s),
+	DialTLS: dialConf.NewDialer(),
 }
 resp, err := client.Get("https://github.com")
 ```

+ 4 - 0
cmd/hpkp-headers/main.go

@@ -28,4 +28,8 @@ func main() {
 	h := hpkp.ParseHeader(resp)
 	j, _ := json.Marshal(h)
 	fmt.Println(string(j))
+
+	h = hpkp.ParseReportOnlyHeader(resp)
+	j, _ = json.Marshal(h)
+	fmt.Println(string(j))
 }

+ 50 - 17
dialer.go

@@ -4,7 +4,7 @@ import (
 	"crypto/tls"
 	"errors"
 	"net"
-	"strings"
+	"strconv"
 )
 
 // Storage is threadsafe hpkp storage interface
@@ -13,32 +13,61 @@ type Storage interface {
 	Add(host string, d *Header)
 }
 
-// PinOnlyDialer returns a dialer that ignores root trusts in favor of known
-// certificate pins
-func PinOnlyDialer(s Storage) func(network, addr string) (net.Conn, error) {
-	return newPinDialer(s, true, nil)
+// StorageReader is threadsafe hpkp storage interface
+type StorageReader interface {
+	Lookup(host string) *Header
+}
+
+// PinFailureReporter callback function to keep track and report on
+// PIN failures
+type PinFailureReporter func(p *PinFailure, reportUri string)
+
+// DialerConfig describes how to verify hpkp info and report failures
+type DialerConfig struct {
+	Storage   StorageReader
+	PinOnly   bool
+	TLSConfig *tls.Config
+	Reporter  PinFailureReporter
+}
+
+// NewDialer returns a dialer for making TLS connections with hpkp support
+func (c *DialerConfig) NewDialer() func(network, addr string) (net.Conn, error) {
+	reporter := c.Reporter
+	if reporter == nil {
+		reporter = emptyReporter
+	}
+
+	return newPinDialer(c.Storage, reporter, c.PinOnly, c.TLSConfig)
 }
 
-// TLSConfigDialer returns a dialer that uses pins in addition to the provided
-// tls.Config options
-func TLSConfigDialer(s Storage, conf *tls.Config) func(network, addr string) (net.Conn, error) {
-	return newPinDialer(s, false, conf)
+// emptyReporter does nothing with a pin failure message
+var emptyReporter = func(p *PinFailure, reportUri string) {
+	return
 }
 
 // newPinDialer returns a function suitable for use as DialTLS
-func newPinDialer(s Storage, pinOnly bool, defaultTLSConfig *tls.Config) func(network, addr string) (net.Conn, error) {
+func newPinDialer(s StorageReader, r PinFailureReporter, pinOnly bool, defaultTLSConfig *tls.Config) func(network, addr string) (net.Conn, error) {
 	return func(network, addr string) (net.Conn, error) {
-		// might need to strip ":https" from addr as well
-		h := s.Lookup(strings.TrimRight(addr, ":443"))
+		host, portStr, err := net.SplitHostPort(addr)
+		if err != nil {
+			return nil, err
+		}
+
+		port, err := strconv.Atoi(portStr)
+		if err != nil {
+			return nil, err
+		}
 
-		if h != nil {
+		if h := s.Lookup(host); h != nil {
+			// initial dial
 			c, err := tls.Dial(network, addr, &tls.Config{InsecureSkipVerify: pinOnly})
 			if err != nil {
 				return c, err
 			}
-			validPin := false
+
 			// intermediates can be pinned as well, loop through leaf-> root looking
-			// for pins
+			// for pin matches
+			validPin := false
 			for _, peercert := range c.ConnectionState().PeerCertificates {
 				peerPin := Fingerprint(peercert)
 				if h.Matches(peerPin) {
@@ -46,12 +75,16 @@ func newPinDialer(s Storage, pinOnly bool, defaultTLSConfig *tls.Config) func(ne
 					break
 				}
 			}
-			if validPin == false {
+			// was a valid pin found?
+			if !validPin {
+				// notify failure callback
+				r(NewPinFailure(host, port, h, c.ConnectionState()))
 				return nil, errors.New("pin was not valid")
 			}
 			return c, nil
 		}
-		// do a normal dial
+
+		// do a normal dial, address isnt in hpkp cache
 		return tls.Dial(network, addr, defaultTLSConfig)
 	}
 }

+ 13 - 2
example_test.go

@@ -1,6 +1,7 @@
 package hpkp_test
 
 import (
+	"fmt"
 	"log"
 	"net/http"
 
@@ -24,8 +25,17 @@ func Example() {
 	})
 
 	client := &http.Client{}
+	dialConf := &hpkp.DialerConfig{
+		Storage:   s,
+		PinOnly:   true,
+		TLSConfig: nil,
+		Reporter: func(p *hpkp.PinFailure, reportUri string) {
+			// TODO: report on PIN failure
+			fmt.Println(p)
+		},
+	}
 	client.Transport = &http.Transport{
-		DialTLS: hpkp.PinOnlyDialer(s),
+		DialTLS: dialConf.NewDialer(),
 	}
 
 	resp, err := client.Get("https://github.com")
@@ -33,5 +43,6 @@ func Example() {
 		log.Fatal(err)
 	}
 
-	log.Println(resp.StatusCode)
+	fmt.Println(resp.StatusCode)
+	// Output: 200
 }

+ 40 - 8
header.go

@@ -14,6 +14,7 @@ type Header struct {
 	IncludeSubDomains bool
 	Permanent         bool
 	Sha256Pins        []string
+	ReportUri         string
 }
 
 // Matches checks whether the provided pin is in the header list
@@ -42,16 +43,47 @@ func ParseHeader(resp *http.Response) *Header {
 		return nil
 	}
 
-	header := &Header{
-		Sha256Pins: []string{},
+	// use the first header per RFC
+	return populate(&Header{}, v[0])
+}
+
+// ParseReportOnlyHeader parses the hpkp information from an http.Reponse.
+// The resulting header information should not be cached as max_age is
+// ignored on HPKP-RO headers per the RFC.
+func ParseReportOnlyHeader(resp *http.Response) *Header {
+	if resp == nil {
+		return nil
+	}
+
+	// only make a header when using TLS
+	if resp.TLS == nil {
+		return nil
+	}
+
+	v, ok := resp.Header["Public-Key-Pins-Report-Only"]
+	if !ok {
+		return nil
 	}
 
-	for _, field := range strings.Split(v[0], ";") {
+	// use the first header per RFC
+	return populate(&Header{}, v[0])
+}
+
+func populate(h *Header, v string) *Header {
+	h.Sha256Pins = []string{}
+
+	for _, field := range strings.Split(v, ";") {
 		field = strings.TrimSpace(field)
 
 		i := strings.Index(field, "pin-sha256")
 		if i >= 0 {
-			header.Sha256Pins = append(header.Sha256Pins, field[i+12:len(field)-1])
+			h.Sha256Pins = append(h.Sha256Pins, field[i+12:len(field)-1])
+			continue
+		}
+
+		i = strings.Index(field, "report-uri")
+		if i >= 0 {
+			h.ReportUri = field[i+12 : len(field)-1]
 			continue
 		}
 
@@ -59,17 +91,17 @@ func ParseHeader(resp *http.Response) *Header {
 		if i >= 0 {
 			ma, err := strconv.Atoi(field[i+8:])
 			if err == nil {
-				header.MaxAge = int64(ma)
+				h.MaxAge = int64(ma)
 			}
 			continue
 		}
 
 		if strings.Contains(field, "includeSubDomains") {
-			header.IncludeSubDomains = true
+			h.IncludeSubDomains = true
 			continue
 		}
 	}
 
-	header.Created = time.Now().Unix()
-	return header
+	h.Created = time.Now().Unix()
+	return h
 }

+ 124 - 2
header_test.go

@@ -64,10 +64,34 @@ func TestParseHeader(t *testing.T) {
 			name: "hpkp header, but over http",
 			response: &http.Response{
 				StatusCode: 200,
-				Header:     map[string][]string{},
+				Header: map[string][]string{
+					"Public-Key-Pins": []string{`max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`},
+				},
 			},
 			expected: nil,
 		},
+		{
+			name: "multiple headers",
+			response: &http.Response{
+				StatusCode: 200,
+				Header: map[string][]string{
+					"Public-Key-Pins": []string{
+						`max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`,
+						`max-age=3001; pin-sha256="bad header"`,
+					},
+				},
+				TLS: &tls.ConnectionState{},
+			},
+			expected: &Header{
+				MaxAge:            3000,
+				IncludeSubDomains: false,
+				Permanent:         false,
+				Sha256Pins: []string{
+					"d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=",
+					"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=",
+				},
+			},
+		},
 		// https://tools.ietf.org/html/rfc7469#section-2.1.5
 		{
 			name: "hpkp header (1)",
@@ -124,6 +148,7 @@ func TestParseHeader(t *testing.T) {
 					"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=",
 					"LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=",
 				},
+				ReportUri: "http://example.com/pkp-report",
 			},
 		},
 		{
@@ -135,7 +160,6 @@ func TestParseHeader(t *testing.T) {
 				},
 				TLS: &tls.ConnectionState{},
 			},
-			// TODO: support Public-Key-Pins-Report-Only
 			expected: nil,
 		},
 		{
@@ -189,6 +213,100 @@ func TestParseHeader(t *testing.T) {
 	}
 }
 
+func TestParseReportOnlyHeader(t *testing.T) {
+	tests := []struct {
+		name     string
+		response *http.Response
+		expected *Header
+	}{
+		{
+			name:     "nil everything",
+			response: nil,
+			expected: nil,
+		},
+		{
+			name: "no header",
+			response: &http.Response{
+				StatusCode: 200,
+			},
+			expected: nil,
+		},
+		{
+			name: "hpkp header, but over http",
+			response: &http.Response{
+				StatusCode: 200,
+				Header: map[string][]string{
+					"Public-Key-Pins": []string{`max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`},
+				},
+			},
+			expected: nil,
+		},
+		{
+			name: "multiple headers",
+			response: &http.Response{
+				StatusCode: 200,
+				Header: map[string][]string{
+					"Public-Key-Pins-Report-Only": []string{
+						`max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`,
+						`max-age=3001; pin-sha256="bad header"`,
+					},
+				},
+				TLS: &tls.ConnectionState{},
+			},
+			expected: &Header{
+				MaxAge:            3000,
+				IncludeSubDomains: false,
+				Permanent:         false,
+				Sha256Pins: []string{
+					"d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=",
+					"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=",
+				},
+			},
+		},
+		// https://tools.ietf.org/html/rfc7469#section-2.1.5
+		{
+			name: "hpkp header (1)",
+			response: &http.Response{
+				StatusCode: 200,
+				Header: map[string][]string{
+					"Public-Key-Pins": []string{`max-age=3000; pin-sha256="d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM="; pin-sha256="E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g="`},
+				},
+				TLS: &tls.ConnectionState{},
+			},
+			expected: nil,
+		},
+		{
+			name: "hpkp header (4)",
+			response: &http.Response{
+				StatusCode: 200,
+				Header: map[string][]string{
+					"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"`},
+				},
+				TLS: &tls.ConnectionState{},
+			},
+			expected: &Header{
+				MaxAge:            2592000,
+				IncludeSubDomains: false,
+				Permanent:         false,
+				Sha256Pins: []string{
+					"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=",
+					"LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=",
+				},
+				ReportUri: "http://example.com/pkp-report",
+			},
+		},
+	}
+
+	for _, test := range tests {
+		out := ParseReportOnlyHeader(test.response)
+		if !equalHeaders(out, test.expected) {
+			t.Logf("want:%v", test.expected)
+			t.Logf("got:%v", out)
+			t.Fatalf("test case failed: %s", test.name)
+		}
+	}
+}
+
 func equalHeaders(a, b *Header) bool {
 	if a == nil && b == nil {
 		return true
@@ -210,6 +328,10 @@ func equalHeaders(a, b *Header) bool {
 		return false
 	}
 
+	if a.ReportUri != b.ReportUri {
+		return false
+	}
+
 	if len(a.Sha256Pins) != len(b.Sha256Pins) {
 		return false
 	}

+ 64 - 0
report.go

@@ -0,0 +1,64 @@
+package hpkp
+
+import (
+	"bytes"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/pem"
+	"time"
+)
+
+// PinFailure hold fields required for POSTing a pin validation failure JSON message
+// to a host's report-uri.
+type PinFailure struct {
+	DateTime                  string   `json:"date-time"`
+	Hostname                  string   `json:"hostname"`
+	Port                      int      `json:"port"`
+	EffectiveExpirationDate   string   `json:"effective-expiration-date"`
+	IncludeSubdomains         bool     `json:"include-subdomains"`
+	NotedHostname             string   `json:"noted-hostname"`
+	ServedCertificateChain    []string `json:"served-certificate-chain"`
+	ValidatedCertificateChain []string `json:"validated-certificate-chain"`
+	KnownPins                 []string `json:"known-pins"`
+}
+
+// NewPinFailure creates a struct to report information on failed hpkp connections
+func NewPinFailure(host string, port int, h *Header, c tls.ConnectionState) (*PinFailure, string) {
+	if h == nil {
+		return nil, ""
+	}
+
+	verifiedChain := []*x509.Certificate{}
+	if len(c.VerifiedChains) > 0 {
+		verifiedChain = c.VerifiedChains[len(c.VerifiedChains)-1]
+	}
+
+	return &PinFailure{
+		DateTime: time.Now().Format(time.RFC3339),
+		Hostname: host,
+		Port:     port,
+		EffectiveExpirationDate:   time.Unix(h.Created+h.MaxAge, 0).UTC().Format(time.RFC3339),
+		IncludeSubdomains:         h.IncludeSubDomains,
+		NotedHostname:             c.ServerName,
+		ServedCertificateChain:    encodeCertificatesPEM(c.PeerCertificates),
+		ValidatedCertificateChain: encodeCertificatesPEM(verifiedChain),
+		KnownPins:                 h.Sha256Pins,
+	}, h.ReportUri
+}
+
+// encodeCertificatesPEM converts a slice of x509 certficates to a slice of PEM encoded strings
+func encodeCertificatesPEM(certs []*x509.Certificate) []string {
+	var pemCerts []string
+
+	var buffer bytes.Buffer
+	for _, cert := range certs {
+		pem.Encode(&buffer, &pem.Block{
+			Type:  "CERTIFICATE",
+			Bytes: cert.Raw,
+		})
+		pemCerts = append(pemCerts, string(buffer.Bytes()))
+		buffer.Reset()
+	}
+
+	return pemCerts
+}

+ 230 - 0
report_test.go

@@ -0,0 +1,230 @@
+package hpkp
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/pem"
+	"reflect"
+	"testing"
+)
+
+var certs = []string{
+	`-----BEGIN CERTIFICATE-----
+MIIHeTCCBmGgAwIBAgIQC/20CQrXteZAwwsWyVKaJzANBgkqhkiG9w0BAQsFADB1
+MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
+d3cuZGlnaWNlcnQuY29tMTQwMgYDVQQDEytEaWdpQ2VydCBTSEEyIEV4dGVuZGVk
+IFZhbGlkYXRpb24gU2VydmVyIENBMB4XDTE2MDMxMDAwMDAwMFoXDTE4MDUxNzEy
+MDAwMFowgf0xHTAbBgNVBA8MFFByaXZhdGUgT3JnYW5pemF0aW9uMRMwEQYLKwYB
+BAGCNzwCAQMTAlVTMRkwFwYLKwYBBAGCNzwCAQITCERlbGF3YXJlMRAwDgYDVQQF
+Ewc1MTU3NTUwMSQwIgYDVQQJExs4OCBDb2xpbiBQIEtlbGx5LCBKciBTdHJlZXQx
+DjAMBgNVBBETBTk0MTA3MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5p
+YTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzEVMBMGA1UEChMMR2l0SHViLCBJbmMu
+MRMwEQYDVQQDEwpnaXRodWIuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
+CgKCAQEA54hc8pZclxgcupjiA/F/OZGRwm/ZlucoQGTNTKmBEgNsrn/mxhngWmPw
+bAvUaLP//T79Jc+1WXMpxMiz9PK6yZRRFuIo0d2bx423NA6hOL2RTtbnfs+y0PFS
+/YTpQSelTuq+Fuwts5v6aAweNyMcYD0HBybkkdosFoDccBNzJ92Ac8I5EVDUc3Or
+/4jSyZwzxu9kdmBlBzeHMvsqdH8SX9mNahXtXxRpwZnBiUjw36PgN+s9GLWGrafd
+02T0ux9Yzd5ezkMxukqEAQ7AKIIijvaWPAJbK/52XLhIy2vpGNylyni/DQD18bBP
+T+ZG1uv0QQP9LuY/joO+FKDOTler4wIDAQABo4IDejCCA3YwHwYDVR0jBBgwFoAU
+PdNQpdagre7zSmAKZdMh1Pj41g8wHQYDVR0OBBYEFIhcSGcZzKB2WS0RecO+oqyH
+IidbMCUGA1UdEQQeMByCCmdpdGh1Yi5jb22CDnd3dy5naXRodWIuY29tMA4GA1Ud
+DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwdQYDVR0f
+BG4wbDA0oDKgMIYuaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL3NoYTItZXYtc2Vy
+dmVyLWcxLmNybDA0oDKgMIYuaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NoYTIt
+ZXYtc2VydmVyLWcxLmNybDBLBgNVHSAERDBCMDcGCWCGSAGG/WwCATAqMCgGCCsG
+AQUFBwIBFhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BTMAcGBWeBDAEBMIGI
+BggrBgEFBQcBAQR8MHowJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0
+LmNvbTBSBggrBgEFBQcwAoZGaHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0Rp
+Z2lDZXJ0U0hBMkV4dGVuZGVkVmFsaWRhdGlvblNlcnZlckNBLmNydDAMBgNVHRMB
+Af8EAjAAMIIBfwYKKwYBBAHWeQIEAgSCAW8EggFrAWkAdgCkuQmQtBhYFIe7E6LM
+Z3AKPDWYBPkb37jjd80OyA3cEAAAAVNhieoeAAAEAwBHMEUCIQCHHSEY/ROK2/sO
+ljbKaNEcKWz6BxHJNPOtjSyuVnSn4QIgJ6RqvYbSX1vKLeX7vpnOfCAfS2Y8lB5R
+NMwk6us2QiAAdgBo9pj4H2SCvjqM7rkoHUz8cVFdZ5PURNEKZ6y7T0/7xAAAAVNh
+iennAAAEAwBHMEUCIQDZpd5S+3to8k7lcDeWBhiJASiYTk2rNAT26lVaM3xhWwIg
+NUqrkIODZpRg+khhp8ag65B8mu0p4JUAmkRDbiYnRvYAdwBWFAaaL9fC7NP14b1E
+sj7HRna5vJkRXMDvlJhV1onQ3QAAAVNhieqZAAAEAwBIMEYCIQDnm3WStlvE99GC
+izSx+UGtGmQk2WTokoPgo1hfiv8zIAIhAPrYeXrBgseA9jUWWoB4IvmcZtshjXso
+nT8MIG1u1zF8MA0GCSqGSIb3DQEBCwUAA4IBAQCLbNtkxuspqycq8h1EpbmAX0wM
+5DoW7hM/FVdz4LJ3Kmftyk1yd8j/PSxRrAQN2Mr/frKeK8NE1cMji32mJbBqpWtK
+/+wC+avPplBUbNpzP53cuTMF/QssxItPGNP5/OT9Aj1BxA/NofWZKh4ufV7cz3pY
+RDS4BF+EEFQ4l5GY+yp4WJA/xSvYsTHWeWxRD1/nl62/Rd9FN2NkacRVozCxRVle
+FrBHTFxqIP6kDnxiLElBrZngtY07ietaYZVLQN/ETyqLQftsf8TecwTklbjvm8NT
+JqbaIVifYwqwNN+4lRxS3F5lNlA/il12IOgbRioLI62o8G0DaEUQgHNf8vSG
+-----END CERTIFICATE-----
+`,
+	`-----BEGIN CERTIFICATE-----
+MIIEtjCCA56gAwIBAgIQDHmpRLCMEZUgkmFf4msdgzANBgkqhkiG9w0BAQsFADBs
+MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
+d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j
+ZSBFViBSb290IENBMB4XDTEzMTAyMjEyMDAwMFoXDTI4MTAyMjEyMDAwMFowdTEL
+MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3
+LmRpZ2ljZXJ0LmNvbTE0MDIGA1UEAxMrRGlnaUNlcnQgU0hBMiBFeHRlbmRlZCBW
+YWxpZGF0aW9uIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
+ggEBANdTpARR+JmmFkhLZyeqk0nQOe0MsLAAh/FnKIaFjI5j2ryxQDji0/XspQUY
+uD0+xZkXMuwYjPrxDKZkIYXLBxA0sFKIKx9om9KxjxKws9LniB8f7zh3VFNfgHk/
+LhqqqB5LKw2rt2O5Nbd9FLxZS99RStKh4gzikIKHaq7q12TWmFXo/a8aUGxUvBHy
+/Urynbt/DvTVvo4WiRJV2MBxNO723C3sxIclho3YIeSwTQyJ3DkmF93215SF2AQh
+cJ1vb/9cuhnhRctWVyh+HA1BV6q3uCe7seT6Ku8hI3UarS2bhjWMnHe1c63YlC3k
+8wyd7sFOYn4XwHGeLN7x+RAoGTMCAwEAAaOCAUkwggFFMBIGA1UdEwEB/wQIMAYB
+Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEF
+BQcDAjA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp
+Z2ljZXJ0LmNvbTBLBgNVHR8ERDBCMECgPqA8hjpodHRwOi8vY3JsNC5kaWdpY2Vy
+dC5jb20vRGlnaUNlcnRIaWdoQXNzdXJhbmNlRVZSb290Q0EuY3JsMD0GA1UdIAQ2
+MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIBFhxodHRwczovL3d3dy5kaWdpY2VydC5j
+b20vQ1BTMB0GA1UdDgQWBBQ901Cl1qCt7vNKYApl0yHU+PjWDzAfBgNVHSMEGDAW
+gBSxPsNpA/i/RwHUmCYaCALvY2QrwzANBgkqhkiG9w0BAQsFAAOCAQEAnbbQkIbh
+hgLtxaDwNBx0wY12zIYKqPBKikLWP8ipTa18CK3mtlC4ohpNiAexKSHc59rGPCHg
+4xFJcKx6HQGkyhE6V6t9VypAdP3THYUYUN9XR3WhfVUgLkc3UHKMf4Ib0mKPLQNa
+2sPIoc4sUqIAY+tzunHISScjl2SFnjgOrWNoPLpSgVh5oywM395t6zHyuqB8bPEs
+1OG9d4Q3A84ytciagRpKkk47RpqF/oOi+Z6Mo8wNXrM9zwR4jxQUezKcxwCmXMS1
+oVWNWlZopCJwqjyBcdmdqEU79OX2olHdx3ti6G8MdOu42vi/hw15UJGQmxg7kVkn
+8TUoE6smftX3eg==
+-----END CERTIFICATE-----
+`,
+	`-----BEGIN CERTIFICATE-----
+MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs
+MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
+d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j
+ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL
+MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3
+LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug
+RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm
++9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW
+PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM
+xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB
+Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3
+hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg
+EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF
+MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA
+FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec
+nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z
+eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF
+hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2
+Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe
+vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep
++OkuE6N36B9K
+-----END CERTIFICATE-----
+`,
+}
+
+func peerCerts(c []string) []*x509.Certificate {
+	out := []*x509.Certificate{}
+	for i := range c {
+		block, _ := pem.Decode([]byte(certs[i]))
+		if block == nil {
+			panic("failed to parse certificate PEM")
+		}
+		cert, err := x509.ParseCertificate(block.Bytes)
+		if err != nil {
+			panic("failed to parse certificate: " + err.Error())
+		}
+		out = append(out, cert)
+	}
+	return out
+}
+
+func TestNewPinFailure(t *testing.T) {
+	tests := []struct {
+		name           string
+		host           string
+		port           int
+		header         *Header
+		connState      tls.ConnectionState
+		expectedReport *PinFailure
+		expectedUri    string
+	}{
+		{
+			name: "nil test",
+		},
+		{
+			name: "basic",
+			host: "github.com",
+			port: 443,
+			header: &Header{
+				Permanent:         true,
+				IncludeSubDomains: false,
+				Sha256Pins: []string{
+					"LvRiGEjRqfzurezaWuj8Wie2gyHMrW5Q06LspMnox7A=",
+				},
+			},
+			connState: tls.ConnectionState{
+				ServerName:       "github.com",
+				PeerCertificates: peerCerts(certs[:1]),
+				VerifiedChains:   [][]*x509.Certificate{peerCerts(certs)},
+			},
+			expectedReport: &PinFailure{
+				Hostname:          "github.com",
+				Port:              443,
+				IncludeSubdomains: false,
+				NotedHostname:     "github.com",
+				KnownPins: []string{
+					"LvRiGEjRqfzurezaWuj8Wie2gyHMrW5Q06LspMnox7A=",
+				},
+				EffectiveExpirationDate:   "1970-01-01T00:00:00Z",
+				ServedCertificateChain:    certs[:1],
+				ValidatedCertificateChain: certs,
+			},
+			expectedUri: "",
+		},
+	}
+
+	for _, test := range tests {
+		pf, uri := NewPinFailure(test.host, test.port, test.header, test.connState)
+
+		if uri != test.expectedUri {
+			t.Logf("want:%v", test.expectedUri)
+			t.Logf("got:%v", uri)
+			t.Fatalf("test case failed: %s", test.name)
+		}
+
+		if !equalFailures(pf, test.expectedReport) {
+			t.Logf("want:%v", test.expectedReport)
+			t.Logf("got:%v", pf)
+			t.Fatalf("test case failed: %s", test.name)
+		}
+	}
+}
+
+func equalFailures(a, b *PinFailure) bool {
+	if a == nil && b == nil {
+		return true
+	}
+
+	if a == nil || b == nil {
+		return false
+	}
+
+	if a.Port != b.Port {
+		return false
+	}
+
+	if a.Hostname != b.Hostname {
+		return false
+	}
+
+	if !reflect.DeepEqual(a.KnownPins, b.KnownPins) {
+		return false
+	}
+
+	if a.NotedHostname != b.NotedHostname {
+		return false
+	}
+
+	if a.IncludeSubdomains != b.IncludeSubdomains {
+		return false
+	}
+
+	if !reflect.DeepEqual(a.ServedCertificateChain, b.ServedCertificateChain) {
+		return false
+	}
+
+	if a.EffectiveExpirationDate != b.EffectiveExpirationDate {
+		return false
+	}
+
+	if !reflect.DeepEqual(a.ValidatedCertificateChain, b.ValidatedCertificateChain) {
+		return false
+	}
+
+	return true
+}

+ 1 - 1
storage.go

@@ -12,7 +12,7 @@ type MemStorage struct {
 }
 
 // NewMemStorage initializes hpkp in-memory datastructure
-func NewMemStorage() Storage {
+func NewMemStorage() *MemStorage {
 	m := &MemStorage{}
 	m.domains = make(map[string]Header)
 	return m