pax_global_header00006660000000000000000000000064136440005700014511gustar00rootroot0000000000000052 comment=98c5f9f71c3d7be6dbf959f8f8cfd686f7d87ef1 crunchy-0.4.0/000077500000000000000000000000001364400057000131655ustar00rootroot00000000000000crunchy-0.4.0/.github/000077500000000000000000000000001364400057000145255ustar00rootroot00000000000000crunchy-0.4.0/.github/FUNDING.yml000066400000000000000000000000171364400057000163400ustar00rootroot00000000000000github: muesli crunchy-0.4.0/.github/workflows/000077500000000000000000000000001364400057000165625ustar00rootroot00000000000000crunchy-0.4.0/.github/workflows/go.yml000066400000000000000000000021521364400057000177120ustar00rootroot00000000000000name: build on: [push, pull_request] jobs: test: strategy: matrix: go-version: [1.11.x, 1.12.x, 1.13.x, 1.14.x] platform: [ubuntu-latest] runs-on: ${{ matrix.platform }} env: GO111MODULE: "on" steps: - name: Install Dependencies run: | sudo apt-get update -qq sudo apt-get install -y cracklib-runtime if: matrix.platform == 'ubuntu-latest' - name: Install Go uses: actions/setup-go@v1 with: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v1 - name: Download Go modules run: go mod download - name: Build run: go build -v ./... - name: Test run: go test ./... - name: Coverage env: COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | go test -race -covermode atomic -coverprofile=profile.cov ./... GO111MODULE=off go get github.com/mattn/goveralls $(go env GOPATH)/bin/goveralls -coverprofile=profile.cov -service=github if: matrix.go-version == '1.14.x' && matrix.platform == 'ubuntu-latest' crunchy-0.4.0/.gitignore000066400000000000000000000004231364400057000151540ustar00rootroot00000000000000# Binaries for programs and plugins *.exe *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ crunchy-0.4.0/.travis.yml000066400000000000000000000014171364400057000153010ustar00rootroot00000000000000language: go os: - linux go: - 1.2.x - 1.3.x - 1.4 - 1.5.x - 1.6.x - 1.7.x - 1.8.x - 1.9.x - 1.10.x - 1.11.x - 1.12.x - 1.13.x - tip matrix: allow_failures: - go: tip exclude: - os: osx go: 1.2.x - os: osx go: 1.3.x - os: osx go: 1.4 - os: osx go: 1.5.x - os: osx go: 1.6.x sudo: required before_install: - if [[ $TRAVIS_GO_VERSION == 1.13* ]]; then go get github.com/axw/gocov/gocov github.com/mattn/goveralls; fi - sudo apt-get update -qq - sudo apt-get install -y cracklib-runtime script: - go test -v -tags ci ./... - if [[ $TRAVIS_GO_VERSION == 1.13* ]]; then $GOPATH/bin/goveralls -service=travis-ci; fi notifications: email: on_success: change on_failure: always crunchy-0.4.0/LICENSE000066400000000000000000000020671364400057000141770ustar00rootroot00000000000000MIT License Copyright (c) 2017 Christian Muehlhaeuser Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. crunchy-0.4.0/README.md000066400000000000000000000072461364400057000144550ustar00rootroot00000000000000crunchy ======= [![Latest Release](https://img.shields.io/github/release/muesli/crunchy.svg)](https://github.com/muesli/crunchy/releases) [![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://godoc.org/github.com/muesli/crunchy) [![Build Status](https://travis-ci.org/muesli/crunchy.svg?branch=master)](https://travis-ci.org/muesli/crunchy) [![Coverage Status](https://coveralls.io/repos/github/muesli/crunchy/badge.svg?branch=master)](https://coveralls.io/github/muesli/crunchy?branch=master) [![Go ReportCard](http://goreportcard.com/badge/muesli/crunchy)](http://goreportcard.com/report/muesli/crunchy) Finds common flaws in passwords. Like cracklib, but written in Go. Detects: - `ErrEmpty`: Empty passwords - `ErrTooShort`: Too short passwords - `ErrTooFewChars`: Too few different characters, like "aabbccdd" - `ErrTooSystematic`: Systematic passwords, like "abcdefgh" or "87654321" - `ErrDictionary`: Passwords from a dictionary / wordlist - `ErrMangledDictionary`: Mangled / reversed passwords, like "p@ssw0rd" or "drowssap" - `ErrHashedDictionary`: Hashed dictionary words, like "5f4dcc3b5aa765d61d8327deb882cf99" (the md5sum of "password") - `ErrFoundHIBP`: Optional hash checks against the haveibeenpwned.com database Your system dictionaries from `/usr/share/dict` will be indexed. If no dictionaries were found, crunchy only relies on the regular sanity checks (`ErrEmpty`, `ErrTooShort`, `ErrTooFewChars` and `ErrTooSystematic`). On Ubuntu it is recommended to install the wordlists distributed with `cracklib-runtime`, on macOS you can install `cracklib-words` from brew. You could also install various other language dictionaries or wordlists, e.g. from skullsecurity.org. crunchy uses the WagnerFischer algorithm to find mangled passwords in your dictionaries. ## Installation Make sure you have a working Go environment (Go 1.2 or higher is required). See the [install instructions](http://golang.org/doc/install.html). To install crunchy, simply run: go get github.com/muesli/crunchy ## Example ```go package main import ( "github.com/muesli/crunchy" "fmt" ) func main() { validator := crunchy.NewValidator() err := validator.Check("12345678") if err != nil { fmt.Printf("The password '12345678' is considered unsafe: %v\n", err) } err = validator.Check("p@ssw0rd") if dicterr, ok := err.(*crunchy.DictionaryError); ok { fmt.Printf("The password 'p@ssw0rd' is too similar to dictionary word '%s' (distance %d)\n", dicterr.Word, dicterr.Distance) } err = validator.Check("d1924ce3d0510b2b2b4604c99453e2e1") if err == nil { // Password is considered acceptable ... } } ``` ## Custom Options ```go package main import ( "github.com/muesli/crunchy" "fmt" ) func main() { validator := crunchy.NewValidatorWithOpts(crunchy.Options{ // MinLength is the minimum length required for a valid password // (must be >= 1, default is 8) MinLength: 10, // MinDiff is the minimum amount of unique characters required for a valid password // (must be >= 1, default is 5) MinDiff: 8, // MinDist is the minimum WagnerFischer distance for mangled password dictionary lookups // (must be >= 0, default is 3) MinDist: 4, // Hashers will be used to find hashed passwords in dictionaries Hashers: []hash.Hash{md5.New(), sha1.New(), sha256.New(), sha512.New()}, // DictionaryPath contains all the dictionaries that will be parsed // (default is /usr/share/dict) DictionaryPath: "/var/my/own/dicts", // Check haveibeenpwned.com database // Default is false CheckHIBP: true, }) ... } ``` crunchy-0.4.0/crunchy.go000066400000000000000000000141121364400057000151660ustar00rootroot00000000000000/* * crunchy - find common flaws in passwords * Copyright (c) 2017-2018, Christian Muehlhaeuser * * For license see LICENSE */ package crunchy import ( "bufio" "encoding/hex" "hash" "os" "path/filepath" "strings" "sync" "unicode" "unicode/utf8" "github.com/xrash/smetrics" ) // Validator is used to setup a new password validator with options and dictionaries type Validator struct { options Options once sync.Once wordsMaxLen int // length of longest word in dictionaries words map[string]struct{} // map to index parsed dictionaries hashedWords map[string]string // maps hash-sum to password } // Options contains all the settings for a Validator type Options struct { // MinLength is the minimum length required for a valid password (>=1, default is 8) MinLength int // MinDiff is the minimum amount of unique characters required for a valid password (>=1, default is 5) MinDiff int // MinDist is the minimum WagnerFischer distance for mangled password dictionary lookups (>=0, default is 3) MinDist int // Hashers will be used to find hashed passwords in dictionaries Hashers []hash.Hash // DictionaryPath contains all the dictionaries that will be parsed (default is /usr/share/dict) DictionaryPath string // Check haveibeenpwned.com database CheckHIBP bool } // NewValidator returns a new password validator with default settings func NewValidator() *Validator { return NewValidatorWithOpts(Options{ MinDist: -1, DictionaryPath: "/usr/share/dict", CheckHIBP: false, }) } // NewValidatorWithOpts returns a new password validator with custom settings func NewValidatorWithOpts(options Options) *Validator { if options.MinLength <= 0 { options.MinLength = 8 } if options.MinDiff <= 0 { options.MinDiff = 5 } if options.MinDist < 0 { options.MinDist = 3 } return &Validator{ options: options, words: make(map[string]struct{}), hashedWords: make(map[string]string), } } // indexDictionaries parses dictionaries/wordlists func (v *Validator) indexDictionaries() { if v.options.DictionaryPath == "" { return } dicts, err := filepath.Glob(filepath.Join(v.options.DictionaryPath, "*")) if err != nil { return } for _, dict := range dicts { file, err := os.Open(dict) if err != nil { continue } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { nw := normalize(scanner.Text()) nwlen := len(nw) if nwlen > v.wordsMaxLen { v.wordsMaxLen = nwlen } // if a word is smaller than the minimum length minus the minimum distance // then any collisons would have been rejected by pre-dictionary checks if nwlen >= v.options.MinLength-v.options.MinDist { v.words[nw] = struct{}{} } for _, hasher := range v.options.Hashers { v.hashedWords[hashsum(nw, hasher)] = nw } } } } // foundInDictionaries returns whether a (mangled) string exists in the indexed dictionaries func (v *Validator) foundInDictionaries(s string) error { v.once.Do(v.indexDictionaries) pw := normalize(s) // normalized password revpw := reverse(pw) // reversed password pwlen := len(pw) // let's check perfect matches first // we can skip this if the pw is longer than the longest word in our dictionary if pwlen <= v.wordsMaxLen { if _, ok := v.words[pw]; ok { return &DictionaryError{ErrDictionary, pw, 0} } if _, ok := v.words[revpw]; ok { return &DictionaryError{ErrMangledDictionary, revpw, 0} } } // find hashed dictionary entries if pwindex, err := hex.DecodeString(pw); err == nil { if word, ok := v.hashedWords[string(pwindex)]; ok { return &HashedDictionaryError{ErrHashedDictionary, word} } } // find mangled / reversed passwords // we can skip this if the pw is longer than the longest word plus our minimum distance if pwlen <= v.wordsMaxLen+v.options.MinDist { for word := range v.words { if dist := smetrics.WagnerFischer(word, pw, 1, 1, 1); dist <= v.options.MinDist { return &DictionaryError{ErrMangledDictionary, word, dist} } if dist := smetrics.WagnerFischer(word, revpw, 1, 1, 1); dist <= v.options.MinDist { return &DictionaryError{ErrMangledDictionary, word, dist} } } } return nil } // Check validates a password for common flaws // It returns nil if the password is considered acceptable. func (v *Validator) Check(password string) error { if strings.TrimSpace(password) == "" { return ErrEmpty } if len(password) < v.options.MinLength { return ErrTooShort } if countUniqueChars(password) < v.options.MinDiff { return ErrTooFewChars } // Inspired by cracklib maxrepeat := 3.0 + (0.09 * float64(len(password))) if countSystematicChars(password) > int(maxrepeat) { return ErrTooSystematic } err := v.foundInDictionaries(password) if err != nil { return err } if v.options.CheckHIBP { err := foundInHIBP(password) if err != nil { return err } } return nil } // Rate grades a password's strength from 0 (weak) to 100 (strong). func (v *Validator) Rate(password string) (uint, error) { if err := v.Check(password); err != nil { return 0, err } l := len(password) systematics := countSystematicChars(password) repeats := l - countUniqueChars(password) var letters, uLetters, numbers, symbols int for len(password) > 0 { r, size := utf8.DecodeRuneInString(password) password = password[size:] if unicode.IsLetter(r) { if unicode.IsUpper(r) { uLetters++ } else { letters++ } } else if unicode.IsNumber(r) { numbers++ } else { symbols++ } } // ADD: number of characters n := l * 4 // ADD: uppercase letters if uLetters > 0 { n += (l - uLetters) * 2 } // ADD: lowercase letters if letters > 0 { n += (l - letters) * 2 } // ADD: numbers n += numbers * 4 // ADD: symbols n += symbols * 6 // REM: letters only if l == letters+uLetters { n -= letters + uLetters } // REM: numbers only if l == numbers { n -= numbers * 4 } // REM: repeat characters (case insensitive) n -= repeats * 4 // REM: systematic characters n -= systematics * 3 if n < 0 { n = 0 } else if n > 100 { n = 100 } return uint(n), nil } crunchy-0.4.0/crunchy_test.go000066400000000000000000000062501364400057000162310ustar00rootroot00000000000000/* * crunchy - find common flaws in passwords * Copyright (c) 2017-2018, Christian Muehlhaeuser * * For license see LICENSE */ package crunchy import ( "crypto/md5" "crypto/sha1" "crypto/sha256" "crypto/sha512" "hash" "strconv" "testing" ) var ( pws = []struct { pw string expected error rating uint }{ // valid passwords {"d1924ce3d0510b2b2b4604c99453e2e1", nil, 100}, {"aCgIknPv", nil, 40}, {"1347902586", nil, 37}, {"aEc!1Edek?", nil, 71}, {"aEc!1Edek?f", nil, 77}, {"aEc!1Edek?f_", nil, 91}, {"aEc!1Edek?f_0", nil, 100}, // invalid passwords {"", ErrEmpty, 0}, {" ", ErrEmpty, 0}, {"crunchy", ErrTooShort, 0}, {"aaaaaaaa", ErrTooFewChars, 0}, {"aabbccdd", ErrTooFewChars, 0}, {"aAbBcCdD", ErrTooFewChars, 0}, {"12345678", ErrTooSystematic, 0}, {"87654321", ErrTooSystematic, 0}, {"abcdefgh", ErrTooSystematic, 0}, {"hgfedcba", ErrTooSystematic, 0}, // haveibeenpwnd {"Qwertyuiop", ErrFoundHIBP, 0}, {"password", ErrDictionary, 0}, {"intoxicate", ErrDictionary, 0}, {"p@ssw0rd", ErrMangledDictionary, 0}, // dictionary with mangling {"!pass@word?", ErrMangledDictionary, 0}, // dictionary with mangling {"drowssap", ErrMangledDictionary, 0}, // reversed dictionary {"?drow@ssap!", ErrMangledDictionary, 0}, // reversed dictionary with mangling // md5 dictionary lookup {"5f4dcc3b5aa765d61d8327deb882cf99", ErrHashedDictionary, 0}, // sha1 dictionary lookup {"5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", ErrHashedDictionary, 0}, // sha256 dictionary lookup {"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", ErrHashedDictionary, 0}, // sha512 dictionary lookup {"b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86", ErrHashedDictionary, 0}, } ) func TestValidator(t *testing.T) { v := NewValidator() pw := "crunchy" err := v.Check(pw) if err == nil { t.Errorf("Expected %v for password '%s', got nil", ErrTooShort, pw) } } func TestRatePassword(t *testing.T) { v := NewValidatorWithOpts(Options{ MinDist: -1, Hashers: []hash.Hash{md5.New(), sha1.New(), sha256.New(), sha512.New()}, DictionaryPath: "/usr/share/dict", }) for _, pw := range pws { if pw.expected == ErrFoundHIBP { continue } r, err := v.Rate(pw.pw) if dicterr, ok := err.(*DictionaryError); ok { err = dicterr.Err } else if hasherr, ok := err.(*HashedDictionaryError); ok { err = hasherr.Err } if r != pw.rating { t.Errorf("Expected rating %d for password '%s', got %d", pw.rating, pw.pw, r) } if err != pw.expected { t.Errorf("Expected %v for password '%s', got %v", pw.expected, pw.pw, err) } } } func TestCheckHIBP(t *testing.T) { v := NewValidatorWithOpts(Options{ CheckHIBP: true, }) for _, pw := range pws { if pw.expected != ErrFoundHIBP { continue } er := v.Check(pw.pw) if er != pw.expected { t.Errorf("Expected %v for password '%s', got %v", pw.expected, pw.pw, er) } } } func BenchmarkValidatePassword(b *testing.B) { v := NewValidator() s := hashsum(strconv.Itoa(b.N), md5.New()) for n := 0; n < b.N; n++ { _ = v.Check(s) } } crunchy-0.4.0/errors.go000066400000000000000000000036271364400057000150400ustar00rootroot00000000000000/* * crunchy - find common flaws in passwords * Copyright (c) 2017, Christian Muehlhaeuser * * For license see LICENSE */ package crunchy import ( "errors" ) // DictionaryError wraps an ErrMangledDictionary with contextual information type DictionaryError struct { Err error Word string Distance int } // HashedDictionaryError wraps an ErrHashedDictionary with contextual information type HashedDictionaryError struct { Err error Word string } func (e *DictionaryError) Error() string { return e.Err.Error() } func (e *HashedDictionaryError) Error() string { return e.Err.Error() } var ( // ErrEmpty gets returned when the password is empty or all whitespace ErrEmpty = errors.New("Password is empty or all whitespace") // ErrTooShort gets returned when the password is not long enough ErrTooShort = errors.New("Password is too short") // ErrTooFewChars gets returned when the password does not contain enough unique characters ErrTooFewChars = errors.New("Password does not contain enough different/unique characters") // ErrTooSystematic gets returned when the password is too systematic (e.g. 123456, abcdef) ErrTooSystematic = errors.New("Password is too systematic") // ErrDictionary gets returned when the password is found in a dictionary ErrDictionary = errors.New("Password is too common / from a dictionary") // ErrMangledDictionary gets returned when the password is mangled, but found in a dictionary ErrMangledDictionary = errors.New("Password is mangled, but too common / from a dictionary") // ErrHashedDictionary gets returned when the password is hashed, but found in a dictionary ErrHashedDictionary = errors.New("Password is hashed, but too common / from a dictionary") // ErrFoundHIBP gets returned when the password has been found on https://haveibeenpwned.com ErrFoundHIBP = errors.New("Password has been found inside haveibeenpwned.com database") ) crunchy-0.4.0/go.mod000066400000000000000000000001601364400057000142700ustar00rootroot00000000000000module github.com/muesli/crunchy go 1.13 require github.com/xrash/smetrics v0.0.0-20170218160415-a3153f7040e9 crunchy-0.4.0/go.sum000066400000000000000000000003411364400057000143160ustar00rootroot00000000000000github.com/xrash/smetrics v0.0.0-20170218160415-a3153f7040e9 h1:w8V9v0qVympSF6GjdjIyeqR7+EVhAF9CBQmkmW7Zw0w= github.com/xrash/smetrics v0.0.0-20170218160415-a3153f7040e9/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= crunchy-0.4.0/hibp.go000066400000000000000000000014251364400057000144400ustar00rootroot00000000000000package crunchy import ( "crypto/sha1" "encoding/hex" "io/ioutil" "net" "net/http" "strings" "time" ) var HttpClient = &http.Client{ Transport: &http.Transport{ Dial: (&net.Dialer{ Timeout: 30 * time.Second, }).Dial, ResponseHeaderTimeout: 10 * time.Second, }, } func foundInHIBP(s string) error { h := sha1.New() h.Write([]byte(s)) result := hex.EncodeToString(h.Sum(nil)) firstFive := result[0:5] restOfHash := strings.ToUpper(result[5:]) url := "https://api.pwnedpasswords.com/range/" + firstFive resp, err := HttpClient.Get(url) if err != nil { return err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return err } if strings.Index(string(body), restOfHash) > -1 { return ErrFoundHIBP } return nil } crunchy-0.4.0/hibp_go1.6.go000066400000000000000000000005461364400057000153550ustar00rootroot00000000000000// +build go1.6 package crunchy import ( "net" "net/http" "time" ) func init() { HttpClient.Transport = &http.Transport{ Dial: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).Dial, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } } crunchy-0.4.0/stringutils.go000066400000000000000000000024711364400057000161070ustar00rootroot00000000000000/* * crunchy - find common flaws in passwords * Copyright (c) 2017, Christian Muehlhaeuser * * For license see LICENSE */ package crunchy import ( "hash" "strings" "unicode" "unicode/utf8" ) // countUniqueChars returns the amount of unique runes in a string func countUniqueChars(s string) int { m := make(map[rune]struct{}) for _, c := range s { c = unicode.ToLower(c) if _, ok := m[c]; !ok { m[c] = struct{}{} } } return len(m) } // countSystematicChars returns how many runes in a string are part of a sequence ('abcdef', '654321') func countSystematicChars(s string) int { var x int rs := []rune(s) for i, c := range rs { if i == 0 { continue } if c == rs[i-1]+1 || c == rs[i-1]-1 { x++ } } return x } // reverse returns the reversed form of a string func reverse(s string) string { var rs []rune for len(s) > 0 { r, size := utf8.DecodeLastRuneInString(s) s = s[:len(s)-size] rs = append(rs, r) } return string(rs) } // normalize returns the trimmed and lowercase version of a string func normalize(s string) string { return strings.TrimSpace(strings.ToLower(s)) } // hashsum returns the hashed sum of a string func hashsum(s string, hasher hash.Hash) string { hasher.Reset() _, _ = hasher.Write([]byte(s)) return string(hasher.Sum(nil)) }