diff --git a/hibp.go b/hibp.go index 5dd01b9..57f1beb 100644 --- a/hibp.go +++ b/hibp.go @@ -49,8 +49,18 @@ var ( // expected length ErrSHA1LengthMismatch = errors.New("SHA1 hash size needs to be 160 bits") + // ErrNTLMLengthMismatch should be used if a given NTLM hash does not match the + // expected length + ErrNTLMLengthMismatch = errors.New("NTLM hash size needs to be 128 bits") + // ErrSHA1Invalid should be used if a given string does not represent a valid SHA1 hash ErrSHA1Invalid = errors.New("not a valid SHA1 hash") + + // ErrNTLMInvalid should be used if a given string does not represent a valid NTLM hash + ErrNTLMInvalid = errors.New("not a valid NTLM hash") + + // ErrUnsupportedHashMode should be used if a given hash mode is not supported + ErrUnsupportedHashMode = errors.New("hash mode not supported") ) // Client is the HIBP client object @@ -80,7 +90,10 @@ func New(options ...Option) Client { // Set defaults c.to = DefaultTimeout - c.PwnedPassAPIOpts = &PwnedPasswordOptions{} + c.PwnedPassAPIOpts = &PwnedPasswordOptions{ + HashMode: HashModeSHA1, + WithPadding: false, + } c.ua = DefaultUserAgent // Set additional options @@ -95,7 +108,10 @@ func New(options ...Option) Client { c.hc = httpClient(c.to) // Associate the different HIBP service APIs with the Client - c.PwnedPassAPI = &PwnedPassAPI{hibp: &c} + c.PwnedPassAPI = &PwnedPassAPI{ + hibp: &c, + ParamMap: make(map[string]string), + } c.BreachAPI = &BreachAPI{hibp: &c} c.PasteAPI = &PasteAPI{hibp: &c} @@ -140,6 +156,18 @@ func WithRateLimitSleep() Option { } } +// WithPwnedNTLMHash sets the hash mode for the PwnedPasswords API to NTLM hashes +// +// Note: This option only affects the generic methods like PwnedPassAPI.CheckPassword +// or PwnedPassAPI.ListHashesPassword. For any specifc method with the hash type in +// the method name, this option is ignored and the hash type of the function is +// forced +func WithPwnedNTLMHash() Option { + return func(c *Client) { + c.PwnedPassAPIOpts.HashMode = HashModeNTLM + } +} + // HTTPReq performs an HTTP request to the corresponding API func (c *Client) HTTPReq(m, p string, q map[string]string) (*http.Request, error) { u, err := url.Parse(p) diff --git a/hibp_test.go b/hibp_test.go index 2d81e28..7389172 100644 --- a/hibp_test.go +++ b/hibp_test.go @@ -37,11 +37,25 @@ func TestNewWithHttpTimeout(t *testing.T) { func TestNewWithPwnedPadding(t *testing.T) { hc := New(WithPwnedPadding()) if !hc.PwnedPassAPIOpts.WithPadding { - t.Errorf("hibp client pwned padding option was not set properly. Expected %v, got: %v", + t.Errorf("hibp client pwned padding option was not set properly. Expected %t, got: %t", true, hc.PwnedPassAPIOpts.WithPadding) } } +// TestNewWithPwnedNTLMHash tests the New() function with the PwnedPadding option +func TestNewWithPwnedNTLMHash(t *testing.T) { + hc := New(WithPwnedNTLMHash()) + if hc.PwnedPassAPIOpts.HashMode != HashModeNTLM { + t.Errorf("hibp client NTLM hash mode option was not set properly. Expected %d, got: %d", + HashModeNTLM, hc.PwnedPassAPIOpts.HashMode) + } + hc = New() + if hc.PwnedPassAPIOpts.HashMode != HashModeSHA1 { + t.Errorf("hibp client SHA-1 hash mode option was not set properly. Expected %d, got: %d", + HashModeSHA1, hc.PwnedPassAPIOpts.HashMode) + } +} + // TestNewWithApiKey tests the New() function with the API key set func TestNewWithApiKey(t *testing.T) { apiKey := os.Getenv("HIBP_API_KEY") diff --git a/md4/LICENSE b/md4/LICENSE new file mode 100644 index 0000000..6a66aea --- /dev/null +++ b/md4/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/md4/md4.go b/md4/md4.go new file mode 100644 index 0000000..e1cc3d0 --- /dev/null +++ b/md4/md4.go @@ -0,0 +1,122 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package md4 implements the MD4 hash algorithm as defined in RFC 1320. +// +// NOTE: MD4 is cryptographically broken and should should only be used +// where compatibility with legacy systems, not security, is the goal. Instead, +// use a secure hash like SHA-256 (from crypto/sha256). +package md4 // import "golang.org/x/crypto/md4" + +import ( + "crypto" + "hash" +) + +func init() { + crypto.RegisterHash(crypto.MD4, New) +} + +// Size is the size of an MD4 checksum in bytes. +const Size = 16 + +// BlockSize is the blocksize of MD4 in bytes. +const BlockSize = 64 + +const ( + _Chunk = 64 + _Init0 = 0x67452301 + _Init1 = 0xEFCDAB89 + _Init2 = 0x98BADCFE + _Init3 = 0x10325476 +) + +// digest represents the partial evaluation of a checksum. +type digest struct { + s [4]uint32 + x [_Chunk]byte + nx int + len uint64 +} + +func (d *digest) Reset() { + d.s[0] = _Init0 + d.s[1] = _Init1 + d.s[2] = _Init2 + d.s[3] = _Init3 + d.nx = 0 + d.len = 0 +} + +// New returns a new hash.Hash computing the MD4 checksum. +func New() hash.Hash { + d := new(digest) + d.Reset() + return d +} + +func (d *digest) Size() int { return Size } + +func (d *digest) BlockSize() int { return BlockSize } + +func (d *digest) Write(p []byte) (nn int, err error) { + nn = len(p) + d.len += uint64(nn) + if d.nx > 0 { + n := len(p) + if n > _Chunk-d.nx { + n = _Chunk - d.nx + } + for i := 0; i < n; i++ { + d.x[d.nx+i] = p[i] + } + d.nx += n + if d.nx == _Chunk { + _Block(d, d.x[0:]) + d.nx = 0 + } + p = p[n:] + } + n := _Block(d, p) + p = p[n:] + if len(p) > 0 { + d.nx = copy(d.x[:], p) + } + return +} + +func (d *digest) Sum(in []byte) []byte { + // Make a copy of d0, so that caller can keep writing and summing. + dc := new(digest) + *dc = *d + + // Padding. Add a 1 bit and 0 bits until 56 bytes mod 64. + plen := dc.len + var tmp [64]byte + tmp[0] = 0x80 + if plen%64 < 56 { + _, _ = dc.Write(tmp[0 : 56-plen%64]) + } else { + _, _ = dc.Write(tmp[0 : 64+56-plen%64]) + } + + // Length in bits. + plen <<= 3 + for i := uint(0); i < 8; i++ { + tmp[i] = byte(plen >> (8 * i)) + } + _, _ = dc.Write(tmp[0:8]) + + if dc.nx != 0 { + panic("dc.nx != 0") + } + + for _, s := range dc.s { + in = append(in, byte(s>>0)) + in = append(in, byte(s>>8)) + in = append(in, byte(s>>16)) + in = append(in, byte(s>>24)) + } + return in +} diff --git a/md4/md4_test.go b/md4/md4_test.go new file mode 100644 index 0000000..684dce5 --- /dev/null +++ b/md4/md4_test.go @@ -0,0 +1,71 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package md4 + +import ( + "fmt" + "io" + "testing" +) + +type md4Test struct { + out string + in string +} + +var golden = []md4Test{ + {"31d6cfe0d16ae931b73c59d7e0c089c0", ""}, + {"bde52cb31de33e46245e05fbdbd6fb24", "a"}, + {"ec388dd78999dfc7cf4632465693b6bf", "ab"}, + {"a448017aaf21d8525fc10ae87aa6729d", "abc"}, + {"41decd8f579255c5200f86a4bb3ba740", "abcd"}, + {"9803f4a34e8eb14f96adba49064a0c41", "abcde"}, + {"804e7f1c2586e50b49ac65db5b645131", "abcdef"}, + {"752f4adfe53d1da0241b5bc216d098fc", "abcdefg"}, + {"ad9daf8d49d81988590a6f0e745d15dd", "abcdefgh"}, + {"1e4e28b05464316b56402b3815ed2dfd", "abcdefghi"}, + {"dc959c6f5d6f9e04e4380777cc964b3d", "abcdefghij"}, + {"1b5701e265778898ef7de5623bbe7cc0", "Discard medicine more than two years old."}, + {"d7f087e090fe7ad4a01cb59dacc9a572", "He who has a shady past knows that nice guys finish last."}, + {"a6f8fd6df617c72837592fc3570595c9", "I wouldn't marry him with a ten foot pole."}, + {"c92a84a9526da8abc240c05d6b1a1ce0", "Free! Free!/A trip/to Mars/for 900/empty jars/Burma Shave"}, + {"f6013160c4dcb00847069fee3bb09803", "The days of the digital watch are numbered. -Tom Stoppard"}, + {"2c3bb64f50b9107ed57640fe94bec09f", "Nepal premier won't resign."}, + {"45b7d8a32c7806f2f7f897332774d6e4", "For every action there is an equal and opposite government program."}, + {"b5b4f9026b175c62d7654bdc3a1cd438", "His money is twice tainted: 'taint yours and 'taint mine."}, + {"caf44e80f2c20ce19b5ba1cab766e7bd", "There is no reason for any individual to have a computer in their home. -Ken Olsen, 1977"}, + {"191fae6707f496aa54a6bce9f2ecf74d", "It's a tiny change to the code and not completely disgusting. - Bob Manchek"}, + {"9ddc753e7a4ccee6081cd1b45b23a834", "size: a.out: bad magic"}, + {"8d050f55b1cadb9323474564be08a521", "The major problem is with sendmail. -Mark Horton"}, + {"ad6e2587f74c3e3cc19146f6127fa2e3", "Give me a rock, paper and scissors and I will move the world. CCFestoon"}, + {"1d616d60a5fabe85589c3f1566ca7fca", "If the enemy is within range, then so are you."}, + {"aec3326a4f496a2ced65a1963f84577f", "It's well we cannot hear the screams/That we create in others' dreams."}, + {"77b4fd762d6b9245e61c50bf6ebf118b", "You remind me of a TV show, but that's all right: I watch it anyway."}, + {"e8f48c726bae5e516f6ddb1a4fe62438", "C is as portable as Stonehedge!!"}, + {"a3a84366e7219e887423b01f9be7166e", "Even if I could be Shakespeare, I think I should still choose to be Faraday. - A. Huxley"}, + {"a6b7aa35157e984ef5d9b7f32e5fbb52", "The fugacity of a constituent in a mixture of gases at a given temperature is proportional to its mole fraction. Lewis-Randall Rule"}, + {"75661f0545955f8f9abeeb17845f3fd6", "How can you write a big system without C++? -Paul Glick"}, +} + +func TestGolden(t *testing.T) { + for i := 0; i < len(golden); i++ { + g := golden[i] + c := New() + for j := 0; j < 3; j++ { + if j < 2 { + _, _ = io.WriteString(c, g.in) + } else { + _, _ = io.WriteString(c, g.in[0:len(g.in)/2]) + c.Sum(nil) + _, _ = io.WriteString(c, g.in[len(g.in)/2:]) + } + s := fmt.Sprintf("%x", c.Sum(nil)) + if s != g.out { + t.Fatalf("md4[%d](%s) = %s want %s", j, g.in, s, g.out) + } + c.Reset() + } + } +} diff --git a/md4/md4block.go b/md4/md4block.go new file mode 100644 index 0000000..547220f --- /dev/null +++ b/md4/md4block.go @@ -0,0 +1,95 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// MD4 block step. +// In its own file so that a faster assembly or C version +// can be substituted easily. + +package md4 + +import "math/bits" + +var ( + shift1 = []int{3, 7, 11, 19} + shift2 = []int{3, 5, 9, 13} + shift3 = []int{3, 9, 11, 15} +) + +var ( + xIndex2 = []uint{0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15} + xIndex3 = []uint{0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15} +) + +func _Block(dig *digest, p []byte) int { + a := dig.s[0] + b := dig.s[1] + c := dig.s[2] + d := dig.s[3] + n := 0 + var X [16]uint32 + for len(p) >= _Chunk { + aa, bb, cc, dd := a, b, c, d + + j := 0 + for i := 0; i < 16; i++ { + X[i] = uint32(p[j]) | uint32(p[j+1])<<8 | uint32(p[j+2])<<16 | uint32(p[j+3])<<24 + j += 4 + } + + // If this needs to be made faster in the future, + // the usual trick is to unroll each of these + // loops by a factor of 4; that lets you replace + // the shift[] lookups with constants and, + // with suitable variable renaming in each + // unrolled body, delete the a, b, c, d = d, a, b, c + // (or you can let the optimizer do the renaming). + // + // The index variables are uint so that % by a power + // of two can be optimized easily by a compiler. + + // Round 1. + for i := uint(0); i < 16; i++ { + x := i + s := shift1[i%4] + f := ((c ^ d) & b) ^ d + a += f + X[x] + a = bits.RotateLeft32(a, s) + a, b, c, d = d, a, b, c + } + + // Round 2. + for i := uint(0); i < 16; i++ { + x := xIndex2[i] + s := shift2[i%4] + g := (b & c) | (b & d) | (c & d) + a += g + X[x] + 0x5a827999 + a = bits.RotateLeft32(a, s) + a, b, c, d = d, a, b, c + } + + // Round 3. + for i := uint(0); i < 16; i++ { + x := xIndex3[i] + s := shift3[i%4] + h := b ^ c ^ d + a += h + X[x] + 0x6ed9eba1 + a = bits.RotateLeft32(a, s) + a, b, c, d = d, a, b, c + } + + a += aa + b += bb + c += cc + d += dd + + p = p[_Chunk:] + n += _Chunk + } + + dig.s[0] = a + dig.s[1] = b + dig.s[2] = c + dig.s[3] = d + return n +} diff --git a/password.go b/password.go index cac5f8e..319a143 100644 --- a/password.go +++ b/password.go @@ -8,11 +8,17 @@ import ( "net/http" "strconv" "strings" + "unicode/utf16" + + "github.com/wneessen/go-hibp/md4" ) // PwnedPassAPI is a HIBP Pwned Passwords API client type PwnedPassAPI struct { - hibp *Client // References back to the parent HIBP client + // References back to the parent HIBP client + hibp *Client + // Query parameter map for additional query parameters passed to request + ParamMap map[string]string } // Match represents a match in the Pwned Passwords API @@ -21,17 +27,44 @@ type Match struct { Count int64 // Represents the number of leaked accounts that hold/held this password } +type HashMode int + +const ( + // HashModeSHA1 is the default hash mode expecting SHA-1 hashes + HashModeSHA1 HashMode = iota + // HashModeNTLM represents the mode that expects and returns NTLM hashes + HashModeNTLM +) + // PwnedPasswordOptions is a struct of additional options for the PP API type PwnedPasswordOptions struct { + // HashMode controls whether the provided hash is in SHA-1 or NTLM format + // HashMode defaults to SHA-1 and can be overridden using the WithNTLMHash() Option + // See: https://haveibeenpwned.com/API/v3#PwnedPasswordsNTLM + HashMode HashMode + // WithPadding controls if the PwnedPassword API returns with padding or not // See: https://haveibeenpwned.com/API/v3#PwnedPasswordsPadding WithPadding bool } // CheckPassword checks the Pwned Passwords database against a given password string +// +// This method will automatically decide whether the hash is in SHA-1 or NTLM format based on +// the Option when the Client was initialized func (p *PwnedPassAPI) CheckPassword(pw string) (*Match, *http.Response, error) { - shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(pw))) - return p.CheckSHA1(shaSum) + switch p.hibp.PwnedPassAPIOpts.HashMode { + case HashModeSHA1: + shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(pw))) + return p.CheckSHA1(shaSum) + case HashModeNTLM: + d := md4.New() + d.Write(stringToUTF16(pw)) + md4Sum := fmt.Sprintf("%x", d.Sum(nil)) + return p.CheckNTLM(md4Sum) + default: + return nil, nil, ErrUnsupportedHashMode + } } // CheckSHA1 checks the Pwned Passwords database against a given SHA1 checksum of a password string @@ -40,13 +73,34 @@ func (p *PwnedPassAPI) CheckSHA1(h string) (*Match, *http.Response, error) { return nil, nil, ErrSHA1LengthMismatch } + p.hibp.PwnedPassAPIOpts.HashMode = HashModeSHA1 pwMatches, hr, err := p.ListHashesPrefix(h[:5]) if err != nil { return &Match{}, hr, err } for _, m := range pwMatches { - if m.Hash == h { + if m.Hash == strings.ToLower(h) { + return &m, hr, nil + } + } + return nil, hr, nil +} + +// CheckNTLM checks the Pwned Passwords database against a given NTLM hash of a password string +func (p *PwnedPassAPI) CheckNTLM(h string) (*Match, *http.Response, error) { + if len(h) != 32 { + return nil, nil, ErrNTLMLengthMismatch + } + + p.hibp.PwnedPassAPIOpts.HashMode = HashModeNTLM + pwMatches, hr, err := p.ListHashesPrefix(h[:5]) + if err != nil { + return &Match{}, hr, err + } + + for _, m := range pwMatches { + if m.Hash == strings.ToLower(h) { return &m, hr, nil } } @@ -56,11 +110,24 @@ func (p *PwnedPassAPI) CheckSHA1(h string) (*Match, *http.Response, error) { // ListHashesPassword checks the Pwned Password API endpoint for all hashes based on a given // password string and returns the a slice of Match as well as the http.Response // +// This method will automatically decide whether the hash is in SHA-1 or NTLM format based on +// the Option when the Client was initialized +// // NOTE: If the `WithPwnedPadding` option is set to true, the returned list will be padded and might // contain junk data func (p *PwnedPassAPI) ListHashesPassword(pw string) ([]Match, *http.Response, error) { - shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(pw))) - return p.ListHashesSHA1(shaSum) + switch p.hibp.PwnedPassAPIOpts.HashMode { + case HashModeSHA1: + shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(pw))) + return p.ListHashesSHA1(shaSum) + case HashModeNTLM: + d := md4.New() + d.Write(stringToUTF16(pw)) + md4Sum := fmt.Sprintf("%x", d.Sum(nil)) + return p.ListHashesNTLM(md4Sum) + default: + return nil, nil, ErrUnsupportedHashMode + } } // ListHashesSHA1 checks the Pwned Password API endpoint for all hashes based on a given @@ -72,6 +139,7 @@ func (p *PwnedPassAPI) ListHashesSHA1(h string) ([]Match, *http.Response, error) if len(h) != 40 { return nil, nil, ErrSHA1LengthMismatch } + p.hibp.PwnedPassAPIOpts.HashMode = HashModeSHA1 dst := make([]byte, hex.DecodedLen(len(h))) if _, err := hex.Decode(dst, []byte(h)); err != nil { return nil, nil, ErrSHA1Invalid @@ -79,8 +147,28 @@ func (p *PwnedPassAPI) ListHashesSHA1(h string) ([]Match, *http.Response, error) return p.ListHashesPrefix(h[:5]) } +// ListHashesNTLM checks the Pwned Password API endpoint for all hashes based on a given +// NTLM hash and returns the a slice of Match as well as the http.Response +// +// NOTE: If the `WithPwnedPadding` option is set to true, the returned list will be padded and might +// contain junk data +func (p *PwnedPassAPI) ListHashesNTLM(h string) ([]Match, *http.Response, error) { + if len(h) != 32 { + return nil, nil, ErrNTLMLengthMismatch + } + p.hibp.PwnedPassAPIOpts.HashMode = HashModeNTLM + dst := make([]byte, hex.DecodedLen(len(h))) + if _, err := hex.Decode(dst, []byte(h)); err != nil { + return nil, nil, ErrNTLMInvalid + } + return p.ListHashesPrefix(h[:5]) +} + // ListHashesPrefix checks the Pwned Password API endpoint for all hashes based on a given -// SHA1 checksum prefix and returns the a slice of Match as well as the http.Response +// SHA-1 or NTLM hash prefix and returns the a slice of Match as well as the http.Response +// +// To decide which HashType is queried for, make sure to set the appropriate HashMode in +// the PwnedPassAPI struct // // NOTE: If the `WithPwnedPadding` option is set to true, the returned list will be padded and might // contain junk data @@ -89,8 +177,16 @@ func (p *PwnedPassAPI) ListHashesPrefix(pf string) ([]Match, *http.Response, err return nil, nil, ErrPrefixLengthMismatch } + switch p.hibp.PwnedPassAPIOpts.HashMode { + case HashModeSHA1: + delete(p.ParamMap, "mode") + case HashModeNTLM: + p.ParamMap["mode"] = "ntlm" + default: + delete(p.ParamMap, "mode") + } au := fmt.Sprintf("%s/range/%s", PasswdBaseURL, pf) - hreq, err := p.hibp.HTTPReq(http.MethodGet, au, nil) + hreq, err := p.hibp.HTTPReq(http.MethodGet, au, p.ParamMap) if err != nil { return nil, nil, err } @@ -129,3 +225,14 @@ func (p *PwnedPassAPI) ListHashesPrefix(pf string) ([]Match, *http.Response, err return pm, hr, nil } + +// stringToUTF16 converts a given string to a UTF-16 little-endian encoded byte slice +func stringToUTF16(s string) []byte { + e := utf16.Encode([]rune(s)) + r := make([]byte, len(e)*2) + for i := 0; i < len(e); i++ { + r[i*2] = byte(e[i]) + r[i*2+1] = byte(e[i] << 8) + } + return r +} diff --git a/password_test.go b/password_test.go index b67d3f6..e52e736 100644 --- a/password_test.go +++ b/password_test.go @@ -17,9 +17,17 @@ const ( // Represents the string: test PwHashInsecure = "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" + // PwHashInsecure is the NTLM hash of an insecure password + // Represents the string: test + PwHashInsecureNTLM = "0cb6948805f797bf2a82807973b89537" + // PwHashSecure is the SHA1 checksum of a secure password // Represents the string: F/0Ws#.%{Z/NVax=OU8Ajf1qTRLNS12p/?s/adX PwHashSecure = "90efc095c82eab44e882fda507cfab1a2cd31fc0" + + // PwHashSecureNTLM is the NTLM hash of a secure password + // Represents the string: F/0Ws#.%{Z/NVax=OU8Ajf1qTRLNS12p/?s/adX + PwHashSecureNTLM = "997f11041d9aa830842e682d1b4207df" ) // TestPwnedPassAPI_CheckPassword verifies the Pwned Passwords API with the CheckPassword method @@ -29,7 +37,7 @@ func TestPwnedPassAPI_CheckPassword(t *testing.T) { pwString string isLeaked bool }{ - {"weak password 'test123' is expected to be leaked", PwStringInsecure, true}, + {"weak password 'test' is expected to be leaked", PwStringInsecure, true}, { "strong, unknown password is expected to be not leaked", PwStringSecure, false, @@ -53,6 +61,38 @@ func TestPwnedPassAPI_CheckPassword(t *testing.T) { } } +// TestPwnedPassAPI_CheckPassword_NTLM verifies the Pwned Passwords API with the CheckPassword method +// with NTLM hashes enabled +func TestPwnedPassAPI_CheckPassword_NTLM(t *testing.T) { + testTable := []struct { + testName string + pwString string + isLeaked bool + }{ + {"weak password 'test' is expected to be leaked", PwStringInsecure, true}, + { + "strong, unknown password is expected to be not leaked", + PwStringSecure, false, + }, + } + hc := New(WithPwnedNTLMHash()) + for _, tc := range testTable { + t.Run(tc.testName, func(t *testing.T) { + m, _, err := hc.PwnedPassAPI.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) + } + }) + } +} + // TestPwnedPassAPI_CheckSHA1 verifies the Pwned Passwords API with the CheckSHA1 method func TestPwnedPassAPI_CheckSHA1(t *testing.T) { testTable := []struct { @@ -89,6 +129,52 @@ func TestPwnedPassAPI_CheckSHA1(t *testing.T) { t.Errorf("password is not expected to be leaked but %d leaks were found in Pwned Passwords DB", m.Count) } + if m != nil && m.Hash != tc.pwHash { + t.Errorf("password hashes don't match, expected: %s, got %s", tc.pwHash, m.Hash) + } + }) + } +} + +// TestPwnedPassAPI_CheckNTLM verifies the Pwned Passwords API with the CheckNTLM method +func TestPwnedPassAPI_CheckNTLM(t *testing.T) { + testTable := []struct { + testName string + pwHash string + isLeaked bool + shouldFail bool + }{ + { + "weak password 'test' is expected to be leaked", + PwHashInsecureNTLM, true, false, + }, + { + "strong, unknown password is expected to be not leaked", + PwHashSecureNTLM, false, false, + }, + { + "empty string should fail", + "", false, true, + }, + } + hc := New() + for _, tc := range testTable { + t.Run(tc.testName, func(t *testing.T) { + m, _, err := hc.PwnedPassAPI.CheckNTLM(tc.pwHash) + if err != nil && !tc.shouldFail { + t.Error(err) + return + } + 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) + } + if m != nil && m.Hash != tc.pwHash { + t.Errorf("password hashes don't match, expected: %s, got %s", tc.pwHash, m.Hash) + } }) } } @@ -188,6 +274,44 @@ func TestPwnedPassAPI_ListHashesSHA1_Errors(t *testing.T) { }) } +// TestPwnedPassAPI_ListHashesNTLM_Errors tests the ListHashesNTLM method's errors +func TestPwnedPassAPI_ListHashesNTLM_Errors(t *testing.T) { + hc := New() + + // Empty hash + t.Run("empty hash", func(t *testing.T) { + _, _, err := hc.PwnedPassAPI.ListHashesNTLM("") + if err == nil { + t.Errorf("ListHashesNTLM with empty hash should fail but didn't") + } + if !errors.Is(err, ErrNTLMLengthMismatch) { + t.Errorf("ListHashesNTLM with empty hash should return ErrNTLMLengthMismatch error but didn't") + } + }) + + // Too long hash + t.Run("too long hash", func(t *testing.T) { + _, _, err := hc.PwnedPassAPI.ListHashesNTLM("FF36DC7D3284A39991ADA90CAF20D1E3C0DADEFAB") + if err == nil { + t.Errorf("ListHashesNTLM with too long hash should fail but didn't") + } + if !errors.Is(err, ErrNTLMLengthMismatch) { + t.Errorf("ListHashesNTLM with too long hash should return ErrNTLMLengthMismatch error but didn't") + } + }) + + // Invalid hash + t.Run("invalid hash", func(t *testing.T) { + _, _, err := hc.PwnedPassAPI.ListHashesNTLM("3284A39991ADA90CAF20D1E3C0DADEFZ") + if err == nil { + t.Errorf("ListHashesNTLM with invalid hash should fail but didn't") + } + if !errors.Is(err, ErrNTLMInvalid) { + t.Errorf("ListHashesNTLM with invalid hash should return ErrSHA1Invalid error but didn't") + } + }) +} + // TestPwnedPassApi_ListHashesSHA1 tests the PwnedPassAPI.ListHashesSHA1 metethod func TestPwnedPassAPI_ListHashesSHA1(t *testing.T) { hc := New() @@ -208,6 +332,26 @@ func TestPwnedPassAPI_ListHashesSHA1(t *testing.T) { } } +// TestPwnedPassApi_ListHashesNTLM tests the PwnedPassAPI.ListHashesNTLM metethod +func TestPwnedPassAPI_ListHashesNTLM(t *testing.T) { + hc := New(WithPwnedNTLMHash()) + + // List length should be >0 + l, _, err := hc.PwnedPassAPI.ListHashesNTLM(PwHashInsecureNTLM) + if err != nil { + t.Errorf("ListHashesNTLM was not supposed to fail, but did: %s", err) + } + if len(l) <= 0 { + t.Errorf("ListHashesNTLM was supposed to return a list longer than 0") + } + + // Hash has wrong size + _, _, err = hc.PwnedPassAPI.ListHashesNTLM(PwStringInsecure) + if err == nil { + t.Errorf("ListHashesNTLM was supposed to fail, but didn't") + } +} + // TestPwnedPassAPI_ListHashesPassword tests the PwnedPassAPI.ListHashesPassword metethod func TestPwnedPassAPI_ListHashesPassword(t *testing.T) { hc := New() @@ -273,3 +417,19 @@ func ExamplePwnedPassAPI_checkSHA1() { // Output: Your password with the hash "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" was found 86495 times in the pwned passwords DB } } + +// ExamplePwnedPassAPI_checkNTLM is a code example to show how to check a given password NTLM hash +// against the HIBP passwords API using the CheckNTLM() method +func ExamplePwnedPassAPI_checkNTLM() { + hc := New() + pwHash := "0cb6948805f797bf2a82807973b89537" // represents the PW: "test" + m, _, err := hc.PwnedPassAPI.CheckNTLM(pwHash) + if err != nil { + panic(err) + } + if m != nil && m.Count != 0 { + fmt.Printf("Your password with the hash %q was found %d times in the pwned passwords DB\n", + m.Hash, m.Count) + // Output: Your password with the hash "0cb6948805f797bf2a82807973b89537" was found 86495 times in the pwned passwords DB + } +}