#27: Implement NTLM hash support for PwnedPassAPI

This PR implements support for NTLM hashes as announced by Troy Hunt:
https://s.pebcak.de/@troyhunt@infosec.exchange/109833758367903768

For this we needed to be able to calculate MD4 hashes, as NTLM basically is calculated like this: `MD4(UTF-16LE(pw))`. For this we ported the official golang.org/x/crypto/md4 package, so we can still claim that "only depends on Go stdlib"

A new Client option has been introduced: `WithPwnedNTLMHash`. If the client is initalized with this option, all generic methods (`ListHashesPassword` and `CheckPassword`) will  operate on NTLM hashes.

Additionally, there are now equivalent methods for checking passwords and listing hashes for NTLM: `CheckNTLM` and `ListHashesNTLM`
This commit is contained in:
Winni Neessen 2023-02-09 17:07:20 +01:00
parent 2b0b51ae17
commit 179cd36d7f
Signed by: wneessen
GPG key ID: 385AC9889632126E
8 changed files with 636 additions and 12 deletions

32
hibp.go
View file

@ -49,8 +49,18 @@ var (
// expected length // expected length
ErrSHA1LengthMismatch = errors.New("SHA1 hash size needs to be 160 bits") 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 should be used if a given string does not represent a valid SHA1 hash
ErrSHA1Invalid = errors.New("not 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 // Client is the HIBP client object
@ -80,7 +90,10 @@ func New(options ...Option) Client {
// Set defaults // Set defaults
c.to = DefaultTimeout c.to = DefaultTimeout
c.PwnedPassAPIOpts = &PwnedPasswordOptions{} c.PwnedPassAPIOpts = &PwnedPasswordOptions{
HashMode: HashModeSHA1,
WithPadding: false,
}
c.ua = DefaultUserAgent c.ua = DefaultUserAgent
// Set additional options // Set additional options
@ -95,7 +108,10 @@ func New(options ...Option) Client {
c.hc = httpClient(c.to) c.hc = httpClient(c.to)
// Associate the different HIBP service APIs with the Client // 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.BreachAPI = &BreachAPI{hibp: &c}
c.PasteAPI = &PasteAPI{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 // HTTPReq performs an HTTP request to the corresponding API
func (c *Client) HTTPReq(m, p string, q map[string]string) (*http.Request, error) { func (c *Client) HTTPReq(m, p string, q map[string]string) (*http.Request, error) {
u, err := url.Parse(p) u, err := url.Parse(p)

View file

@ -37,11 +37,25 @@ func TestNewWithHttpTimeout(t *testing.T) {
func TestNewWithPwnedPadding(t *testing.T) { func TestNewWithPwnedPadding(t *testing.T) {
hc := New(WithPwnedPadding()) hc := New(WithPwnedPadding())
if !hc.PwnedPassAPIOpts.WithPadding { 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) 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 // TestNewWithApiKey tests the New() function with the API key set
func TestNewWithApiKey(t *testing.T) { func TestNewWithApiKey(t *testing.T) {
apiKey := os.Getenv("HIBP_API_KEY") apiKey := os.Getenv("HIBP_API_KEY")

27
md4/LICENSE Normal file
View file

@ -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.

122
md4/md4.go Normal file
View file

@ -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
}

71
md4/md4_test.go Normal file
View file

@ -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()
}
}
}

95
md4/md4block.go Normal file
View file

@ -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
}

View file

@ -8,11 +8,17 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"unicode/utf16"
"github.com/wneessen/go-hibp/md4"
) )
// PwnedPassAPI is a HIBP Pwned Passwords API client // PwnedPassAPI is a HIBP Pwned Passwords API client
type PwnedPassAPI struct { 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 // 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 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 // PwnedPasswordOptions is a struct of additional options for the PP API
type PwnedPasswordOptions struct { 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 // WithPadding controls if the PwnedPassword API returns with padding or not
// See: https://haveibeenpwned.com/API/v3#PwnedPasswordsPadding // See: https://haveibeenpwned.com/API/v3#PwnedPasswordsPadding
WithPadding bool WithPadding bool
} }
// CheckPassword checks the Pwned Passwords database against a given password string // 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) { func (p *PwnedPassAPI) CheckPassword(pw string) (*Match, *http.Response, error) {
switch p.hibp.PwnedPassAPIOpts.HashMode {
case HashModeSHA1:
shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(pw))) shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(pw)))
return p.CheckSHA1(shaSum) 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 // 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 return nil, nil, ErrSHA1LengthMismatch
} }
p.hibp.PwnedPassAPIOpts.HashMode = HashModeSHA1
pwMatches, hr, err := p.ListHashesPrefix(h[:5]) pwMatches, hr, err := p.ListHashesPrefix(h[:5])
if err != nil { if err != nil {
return &Match{}, hr, err return &Match{}, hr, err
} }
for _, m := range pwMatches { 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 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 // 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 // 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 // NOTE: If the `WithPwnedPadding` option is set to true, the returned list will be padded and might
// contain junk data // contain junk data
func (p *PwnedPassAPI) ListHashesPassword(pw string) ([]Match, *http.Response, error) { func (p *PwnedPassAPI) ListHashesPassword(pw string) ([]Match, *http.Response, error) {
switch p.hibp.PwnedPassAPIOpts.HashMode {
case HashModeSHA1:
shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(pw))) shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(pw)))
return p.ListHashesSHA1(shaSum) 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 // 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 { if len(h) != 40 {
return nil, nil, ErrSHA1LengthMismatch return nil, nil, ErrSHA1LengthMismatch
} }
p.hibp.PwnedPassAPIOpts.HashMode = HashModeSHA1
dst := make([]byte, hex.DecodedLen(len(h))) dst := make([]byte, hex.DecodedLen(len(h)))
if _, err := hex.Decode(dst, []byte(h)); err != nil { if _, err := hex.Decode(dst, []byte(h)); err != nil {
return nil, nil, ErrSHA1Invalid return nil, nil, ErrSHA1Invalid
@ -79,8 +147,28 @@ func (p *PwnedPassAPI) ListHashesSHA1(h string) ([]Match, *http.Response, error)
return p.ListHashesPrefix(h[:5]) 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 // 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 // NOTE: If the `WithPwnedPadding` option is set to true, the returned list will be padded and might
// contain junk data // contain junk data
@ -89,8 +177,16 @@ func (p *PwnedPassAPI) ListHashesPrefix(pf string) ([]Match, *http.Response, err
return nil, nil, ErrPrefixLengthMismatch 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) 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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -129,3 +225,14 @@ func (p *PwnedPassAPI) ListHashesPrefix(pf string) ([]Match, *http.Response, err
return pm, hr, nil 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
}

View file

@ -17,9 +17,17 @@ const (
// Represents the string: test // Represents the string: test
PwHashInsecure = "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" 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 // PwHashSecure is the SHA1 checksum of a secure password
// Represents the string: F/0Ws#.%{Z/NVax=OU8Ajf1qTRLNS12p/?s/adX // Represents the string: F/0Ws#.%{Z/NVax=OU8Ajf1qTRLNS12p/?s/adX
PwHashSecure = "90efc095c82eab44e882fda507cfab1a2cd31fc0" 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 // TestPwnedPassAPI_CheckPassword verifies the Pwned Passwords API with the CheckPassword method
@ -29,7 +37,7 @@ func TestPwnedPassAPI_CheckPassword(t *testing.T) {
pwString string pwString string
isLeaked bool 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", "strong, unknown password is expected to be not leaked",
PwStringSecure, false, 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 // TestPwnedPassAPI_CheckSHA1 verifies the Pwned Passwords API with the CheckSHA1 method
func TestPwnedPassAPI_CheckSHA1(t *testing.T) { func TestPwnedPassAPI_CheckSHA1(t *testing.T) {
testTable := []struct { 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", t.Errorf("password is not expected to be leaked but %d leaks were found in Pwned Passwords DB",
m.Count) 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 // TestPwnedPassApi_ListHashesSHA1 tests the PwnedPassAPI.ListHashesSHA1 metethod
func TestPwnedPassAPI_ListHashesSHA1(t *testing.T) { func TestPwnedPassAPI_ListHashesSHA1(t *testing.T) {
hc := New() 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 // TestPwnedPassAPI_ListHashesPassword tests the PwnedPassAPI.ListHashesPassword metethod
func TestPwnedPassAPI_ListHashesPassword(t *testing.T) { func TestPwnedPassAPI_ListHashesPassword(t *testing.T) {
hc := New() 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 // 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
}
}