mirror of
https://github.com/wneessen/go-hibp.git
synced 2024-11-14 01:12:54 +01:00
Winni Neessen
179cd36d7f
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`
238 lines
6.9 KiB
Go
238 lines
6.9 KiB
Go
package hibp
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf16"
|
|
|
|
"github.com/wneessen/go-hibp/md4"
|
|
)
|
|
|
|
// PwnedPassAPI is a HIBP Pwned Passwords API client
|
|
type PwnedPassAPI struct {
|
|
// 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
|
|
type Match struct {
|
|
Hash string // SHA1 hash of the matching 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
|
|
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) {
|
|
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
|
|
func (p *PwnedPassAPI) CheckSHA1(h string) (*Match, *http.Response, error) {
|
|
if len(h) != 40 {
|
|
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 == 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 nil, hr, nil
|
|
}
|
|
|
|
// 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) {
|
|
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
|
|
// SHA1 checksum 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) 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
|
|
}
|
|
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
|
|
// 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
|
|
func (p *PwnedPassAPI) ListHashesPrefix(pf string) ([]Match, *http.Response, error) {
|
|
if len(pf) != 5 {
|
|
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, p.ParamMap)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
hr, err := p.hibp.hc.Do(hreq)
|
|
if err != nil {
|
|
return nil, hr, err
|
|
}
|
|
defer func() {
|
|
_ = hr.Body.Close()
|
|
}()
|
|
if hr.StatusCode != 200 {
|
|
return nil, hr, fmt.Errorf("HTTP %s: %w", hr.Status, ErrNonPositiveResponse)
|
|
}
|
|
|
|
var pm []Match
|
|
so := bufio.NewScanner(hr.Body)
|
|
for so.Scan() {
|
|
hp := strings.SplitN(so.Text(), ":", 2)
|
|
fh := fmt.Sprintf("%s%s", strings.ToLower(pf), strings.ToLower(hp[0]))
|
|
hc, err := strconv.ParseInt(hp[1], 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if hc == 0 {
|
|
continue
|
|
}
|
|
pm = append(pm, Match{
|
|
Hash: fh,
|
|
Count: hc,
|
|
})
|
|
}
|
|
|
|
if err := so.Err(); err != nil {
|
|
return nil, hr, 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
|
|
}
|