diff --git a/README.md b/README.md index 3eed816..f29be47 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,26 @@ -# go-hibp -Simple Go implementation of the HIBP Passwords API +# go-hibp - Simple go client for the HIBP API + +[![Go Reference](https://pkg.go.dev/badge/github.com/wneessen/go-hibp.svg)](https://pkg.go.dev/github.com/wneessen/go-hibp) [![Go Report Card](https://goreportcard.com/badge/github.com/wneessen/go-hibp)](https://goreportcard.com/report/github.com/wneessen/go-hibp) [![Build Status](https://api.cirrus-ci.com/github/wneessen/go-hibp.svg)](https://cirrus-ci.com/github/wneessen/go-hibp) + +## Usage + +### Pwned Passwords API +```go +package main + +import ( + "fmt" + "github.com/wneessen/go-hibp" +) + +func main() { + hc := New() + m, _, err := hc.PwnedPassword.CheckPassword("test123") + if err != nil { + panic(err) + } + if m != nil && m.Count != 0 { + fmt.Println("Your password was found in the pwned passwords DB") + } +} +``` \ No newline at end of file diff --git a/hibp.go b/hibp.go index b3fee5a..c122f42 100644 --- a/hibp.go +++ b/hibp.go @@ -1,44 +1,98 @@ -package go_hibp +package hibp import ( - "bufio" - "crypto/sha1" + "crypto/tls" "fmt" "net/http" - "strings" + "net/url" "time" ) -// HIBPUrl represents the main API url for the HIBP password API -const HIBPUrl = "https://api.pwnedpasswords.com/range/" +// Version represents the version of this package +const Version = "0.1.1" -// Check queries the HIBP database and checks if a given string is was found -func Check(p string) (ip bool, err error) { - shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(p))) - fp := shaSum[0:5] - sp := shaSum[5:] - ip = false +// Client is the HIBP client object +type Client struct { + hc *http.Client // HTTP client to perform the API requests + to time.Duration // HTTP client timeout + ak string // HIBP API key - httpClient := &http.Client{Timeout: time.Second * 2} - httpRes, err := httpClient.Get(HIBPUrl + fp) - if err != nil { - return false, err - } - defer func() { - err = httpRes.Body.Close() - }() - - scanObj := bufio.NewScanner(httpRes.Body) - for scanObj.Scan() { - scanLine := strings.SplitN(scanObj.Text(), ":", 2) - if strings.ToLower(scanLine[0]) == sp { - ip = true - break - } - } - if err := scanObj.Err(); err != nil { - return ip, err - } - - return ip, nil + PwnedPassword *PwnedPassword // Reference to the PwnedPassword API +} + +// Option is a function that is used for grouping of Client options. +type Option func(*Client) + +// New creates and returns a new HIBP client object +func New(options ...Option) *Client { + c := &Client{} + + // Set defaults + c.to = time.Second * 5 + + // Set additional options + for _, opt := range options { + opt(c) + } + + // Add a http client to the Client object + c.hc = httpClient(c.to) + + // Associate the different HIBP service APIs with the Client + c.PwnedPassword = &PwnedPassword{hc: c} + + return c +} + +// WithHttpTimeout overrides the default http client timeout +func WithHttpTimeout(t time.Duration) Option { + return func(c *Client) { + c.to = t + } +} + +// WithApiKey set the optional API key to the Client object +func WithApiKey(k string) Option { + return func(c *Client) { + c.ak = k + } +} + +// HttpReq performs an HTTP request to the corresponding API +func (c *Client) HttpReq(m, p string) (*http.Request, error) { + u, err := url.Parse(p) + if err != nil { + return nil, err + } + + hr, err := http.NewRequest(m, u.String(), nil) + if err != nil { + return nil, err + } + hr.Header.Set("Accept", "application/json") + hr.Header.Set("User-Agent", fmt.Sprintf("go-hibp v%s - https://github.com/wneessen/go-hibp", Version)) + + if c.ak != "" { + hr.Header["hibp-api-key"] = []string{c.ak} + } + + return hr, nil +} + +// httpClient returns a custom http client for the HIBP Client object +func httpClient(to time.Duration) *http.Client { + tlsConfig := &tls.Config{ + MaxVersion: tls.VersionTLS13, + MinVersion: tls.VersionTLS12, + } + httpTransport := &http.Transport{TLSClientConfig: tlsConfig} + httpClient := &http.Client{ + Transport: httpTransport, + Timeout: 5 * time.Second, + } + if to.Nanoseconds() > 0 { + httpClient.Timeout = to + } + + return httpClient } diff --git a/hibp_test.go b/hibp_test.go new file mode 100644 index 0000000..54af4bc --- /dev/null +++ b/hibp_test.go @@ -0,0 +1,26 @@ +package hibp + +import ( + "testing" + "time" +) + +// TestNew tests the New() function +func TestNew(t *testing.T) { + hc := New() + if hc == nil { + t.Errorf("hibp client creation failed") + } +} + +// TestNewWithHttpTimeout tests the New() function +func TestNewWithHttpTimeout(t *testing.T) { + hc := New(WithHttpTimeout(time.Second * 10)) + if hc == nil { + t.Errorf("hibp client creation failed") + } + if hc.to != time.Second*10 { + t.Errorf("hibp client timeout option was not set properly. Expected %d, got: %d", + time.Second*10, hc.to) + } +} diff --git a/password.go b/password.go new file mode 100644 index 0000000..a875daa --- /dev/null +++ b/password.go @@ -0,0 +1,84 @@ +package hibp + +import ( + "bufio" + "crypto/sha1" + "fmt" + "net/http" + "strconv" + "strings" +) + +// PwnedPassword is a HIBP Pwned Passwords API client +type PwnedPassword struct { + hc *Client +} + +// Match represents a match in the Pwned Passwords API +type Match struct { + Hash string + Count int64 +} + +// CheckPassword checks the Pwned Passwords database against a given password string +func (p *PwnedPassword) CheckPassword(pw string) (*Match, *http.Response, error) { + shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(pw))) + return p.CheckSHA1(shaSum) +} + +// CheckSHA1 checks the Pwned Passwords database against a given SHA1 checksum of a password +func (p *PwnedPassword) CheckSHA1(h string) (*Match, *http.Response, error) { + pwMatches, hr, err := p.apiCall(h) + if err != nil { + return &Match{}, hr, err + } + + for _, m := range pwMatches { + if m.Hash == h { + return &m, hr, nil + } + } + + return nil, hr, nil +} + +// apiCall performs the API call to the Pwned Password API endpoint and returns +// the http.Response +func (p *PwnedPassword) apiCall(h string) ([]Match, *http.Response, error) { + sh := h[:5] + hreq, err := p.hc.HttpReq(http.MethodGet, fmt.Sprintf("https://api.pwnedpasswords.com/range/%s", sh)) + if err != nil { + return nil, nil, err + } + hr, err := p.hc.hc.Do(hreq) + if err != nil { + return nil, nil, err + } + if hr.StatusCode != 200 { + return nil, hr, fmt.Errorf("API responded with non HTTP-200: %s", hr.Status) + } + defer func() { + _ = hr.Body.Close() + }() + + var pwMatches []Match + scanObj := bufio.NewScanner(hr.Body) + for scanObj.Scan() { + hp := strings.SplitN(scanObj.Text(), ":", 2) + fh := fmt.Sprintf("%s%s", sh, strings.ToLower(hp[0])) + hc, err := strconv.ParseInt(hp[1], 10, 64) + if err != nil { + continue + } + pwMatches = append(pwMatches, Match{ + Hash: fh, + Count: hc, + }) + } + + if err := scanObj.Err(); err != nil { + return nil, nil, err + } + + return pwMatches, hr, nil +} diff --git a/password_test.go b/password_test.go new file mode 100644 index 0000000..ab76b73 --- /dev/null +++ b/password_test.go @@ -0,0 +1,66 @@ +package hibp + +import ( + "testing" +) + +// TestPwnedPasswordString verifies the Pwned Passwords API with the CheckPassword method +func TestPwnedPasswordString(t *testing.T) { + testTable := []struct { + testName string + pwString string + isLeaked bool + }{ + {"weak password 'test123' is expected to be leaked", "test123", true}, + {"strong, unknown password is expected to be not leaked", + "F/0Ws#.%{Z/NVax=OU8Ajf1qTRLNS12p/?s/adX", false}, + } + hc := New() + + for _, tc := range testTable { + t.Run(tc.testName, func(t *testing.T) { + m, _, err := hc.PwnedPassword.CheckPassword(tc.pwString) + if err != nil { + t.Error(err) + } + if m == nil && tc.isLeaked { + t.Errorf("password is expected to be leaked but 0 leaks were returned in Pwned Passwords DB") + } + if m != nil && m.Count > 0 && !tc.isLeaked { + t.Errorf("password is not expected to be leaked but %d leaks were found in Pwned Passwords DB", + m.Count) + } + }) + } +} + +// TestPwnedPasswordHash verifies the Pwned Passwords API with the CheckSHA1 method +func TestPwnedPasswordHash(t *testing.T) { + testTable := []struct { + testName string + pwHash string + isLeaked bool + }{ + {"weak password 'test123' is expected to be leaked", + "7288edd0fc3ffcbe93a0cf06e3568e28521687bc", true}, + {"strong, unknown password is expected to be not leaked", + "90efc095c82eab44e882fda507cfab1a2cd31fc0", false}, + } + hc := New() + + for _, tc := range testTable { + t.Run(tc.testName, func(t *testing.T) { + m, _, err := hc.PwnedPassword.CheckSHA1(tc.pwHash) + if err != nil { + t.Error(err) + } + if m == nil && tc.isLeaked { + t.Errorf("password is expected to be leaked but 0 leaks were returned in Pwned Passwords DB") + } + if m != nil && m.Count > 0 && !tc.isLeaked { + t.Errorf("password is not expected to be leaked but %d leaks were found in Pwned Passwords DB", + m.Count) + } + }) + } +}