Codebase list golang-github-muesli-crunchy / fresh-snapshots/main crunchy.go
fresh-snapshots/main

Tree @fresh-snapshots/main (Download .tar.gz)

crunchy.go @fresh-snapshots/mainraw · history · blame

/*
 * crunchy - find common flaws in passwords
 *     Copyright (c) 2017-2018, Christian Muehlhaeuser <muesli@gmail.com>
 *
 *   For license see LICENSE
 */

package crunchy

import (
	"bufio"
	"encoding/hex"
	"hash"
	"os"
	"path/filepath"
	"regexp"
	"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
	// MustContainDigit requires at least one digit for a valid password
	MustContainDigit bool
	// MustContainSymbol requires at least one special symbol for a valid password
	MustContainSymbol bool
}

// NewValidator returns a new password validator with default settings
func NewValidator() *Validator {
	return NewValidatorWithOpts(Options{
		MinDist:           -1,
		DictionaryPath:    "/usr/share/dict",
		CheckHIBP:         false,
		MustContainDigit:  false,
		MustContainSymbol: 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
		}

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

		file.Close()
	}
}

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

	if v.options.MustContainDigit {
		validateDigit := regexp.MustCompile(`[0-9]+`)
		if !validateDigit.MatchString(password) {
			return ErrNoDigits
		}
	}

	if v.options.MustContainSymbol {
		validateSymbols := regexp.MustCompile(`[^\w\s]+`)
		if !validateSymbols.MatchString(password) {
			return ErrNoSymbols
		}
	}

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