package main import ( "bufio" "fmt" "log" "os" "github.com/tlinden/valpass" ) const template string = ` Metric Random Threshhold Result ------------------------------------------------------------------ Compression rate 0%% min %d%% %d%% Character distribution 100%% min %0.2f%% %0.2f%% Character entropy 8.0 bits/char min %0.2f %0.2f bits/char Character redundancy 0.0%% max %0.2f%% %0.2f%% Dictionary match false false %t ------------------------------------------------------------------ Validation response %t ` func main() { opts := valpass.Options{ Compress: valpass.MIN_COMPRESS, CharDistribution: valpass.MIN_DIST, Entropy: valpass.MIN_ENTROPY, Dictionary: &valpass.Dictionary{Words: ReadDict("t/american-english")}, } res, err := valpass.Validate(os.Args[1], opts) if err != nil { log.Fatal(err) } fmt.Printf( template, opts.Compress, res.Compress, opts.CharDistribution, res.CharDistribution, opts.Entropy, res.Entropy, 100-opts.CharDistribution, 100-res.CharDistribution, res.DictionaryMatch, res.Ok, ) if !res.Ok { os.Exit(1) } } func ReadDict(path string) []string { file, err := os.Open(path) if err != nil { panic(err) } defer file.Close() var lines []string scanner := bufio.NewScanner(file) for scanner.Scan() { lines = append(lines, scanner.Text()) } return lines }
package valpass // via https://github.com/adrg/strutil, MIT licensed // Copyright (c) 2019-2023 Adrian-George Bostan. import ( "strings" ) // Levenshtein represents the Levenshtein metric for measuring the similarity // between sequences. // // For more information see https://en.wikipedia.org/wiki/Levenshtein_distance. type Levenshtein struct { // CaseSensitive specifies if the string comparison is case sensitive. CaseSensitive bool // InsertCost represents the Levenshtein cost of a character insertion. InsertCost int // InsertCost represents the Levenshtein cost of a character deletion. DeleteCost int // InsertCost represents the Levenshtein cost of a character substitution. ReplaceCost int } // NewLevenshtein returns a new Levenshtein string metric. // // Default options: // // CaseSensitive: true // InsertCost: 1 // DeleteCost: 1 // ReplaceCost: 1 func NewLevenshtein() *Levenshtein { return &Levenshtein{ CaseSensitive: true, InsertCost: 1, DeleteCost: 1, ReplaceCost: 1, } } // Compare returns the Levenshtein similarity of a and b. The returned // similarity is a number between 0 and 1. Larger similarity numbers indicate // closer matches. func (m *Levenshtein) Compare(a, b string) float64 { distance, maxLen := m.distance(a, b) return 1 - float64(distance)/float64(maxLen) } // Distance returns the Levenshtein distance between a and b. Lower distances // indicate closer matches. A distance of 0 means the strings are identical. func (m *Levenshtein) Distance(a, b string) int { distance, _ := m.distance(a, b) return distance } // Min returns the value of the smallest argument, // or 0 if no arguments are provided. func Min(args ...int) int { if len(args) == 0 { return 0 } if len(args) == 1 { return args[0] } min := args[0] for _, arg := range args[1:] { if min > arg { min = arg } } return min } // Max returns the value of the largest argument, // or 0 if no arguments are provided. func Max(args ...int) int { if len(args) == 0 { return 0 } if len(args) == 1 { return args[0] } max := args[0] for _, arg := range args[1:] { if max < arg { max = arg } } return max } func (m *Levenshtein) distance(a, b string) (int, int) { // Lower terms if case insensitive comparison is specified. if !m.CaseSensitive { a = strings.ToLower(a) b = strings.ToLower(b) } runesA, runesB := []rune(a), []rune(b) // Check if both terms are empty. lenA, lenB := len(runesA), len(runesB) if lenA == 0 && lenB == 0 { return 0, 0 } // Check if one of the terms is empty. maxLen := Max(lenA, lenB) if lenA == 0 { return m.InsertCost * lenB, maxLen } if lenB == 0 { return m.DeleteCost * lenA, maxLen } // Initialize cost slice. prevCol := make([]int, lenB+1) for i := 0; i <= lenB; i++ { prevCol[i] = i } // Calculate distance. col := make([]int, lenB+1) for i := 0; i < lenA; i++ { col[0] = i + 1 for j := 0; j < lenB; j++ { delCost := prevCol[j+1] + m.DeleteCost insCost := col[j] + m.InsertCost subCost := prevCol[j] if runesA[i] != runesB[j] { subCost += m.ReplaceCost } col[j+1] = Min(delCost, insCost, subCost) } col, prevCol = prevCol, col } return prevCol[lenB], maxLen }
// Package valpass can be used to validate password quality using different metrics. package valpass import ( "bytes" "compress/flate" "fmt" "math" "strings" ) // Dictionary is a container struct to store and submit a dictionary of words. type Dictionary struct { Words []string // Contains the actual dictionary. Submatch bool // Set to true to enable submatches, e.g. 'foo' would match 'foobar', default is false. Fuzzy bool // Set to true to enable more lax dictionary checks, default is false. } // Options struct can be used to configure the validator, turn on/off // certain validator functions and tune the thresholds when to flag a // password as valid. // // Set option to zero or false to disable the feature. type Options struct { Compress int // minimum compression rate in percent, default 10% CharDistribution float64 // minimum character distribution in percent, default 10% Entropy float64 // minimum entropy value in bits/char, default 3 bits/s Dictionary *Dictionary // lookup given dictionary, the caller has to provide it } const ( MIN_COMPRESS int = 10 MIN_DIST float64 = 10.0 MIN_ENTROPY float64 = 3.0 MIN_DICT_LEN int = 5000 MAX_CHARS int = 95 // maximum printable US ASCII chars // we start our ascii arrays at char(32), so to have max 95 // elements in the slice, we subtract 32 from each ascii code ascii_base byte = 32 ) // Result stores the results of all validations. type Result struct { Ok bool // overall result DictionaryMatch bool // true if the password matched a dictionary entry Compress int // actual compression rate in percent CharDistribution float64 // actual character distribution in percent Entropy float64 // actual entropy value in bits/chars } // Validate validates a given password. You can tune its behavior // using the Options struct. However, options are optional, there are // sensible defaults builtins. // // The returned Result struct returns the password quality. func Validate(passphrase string, opts ...Options) (Result, error) { result := Result{Ok: true} // defaults, see above options := Options{ Compress: MIN_COMPRESS, CharDistribution: MIN_DIST, Entropy: MIN_ENTROPY, Dictionary: nil, } if len(opts) == 1 { options = opts[0] } // execute the actual validation checks if options.Entropy > 0 { var entropy float64 var err error entropy, err = getEntropy(passphrase) if err != nil { return result, err } if entropy <= options.Entropy { result.Ok = false } result.Entropy = entropy } if options.Compress > 0 { compression, err := getCompression([]byte(passphrase)) if err != nil { return result, err } if compression >= options.Compress { result.Ok = false } result.Compress = compression } if options.CharDistribution > 0 { var dist = getDistribution(passphrase) if dist <= options.CharDistribution { result.Ok = false } result.CharDistribution = dist } if options.Dictionary != nil { match, err := getDictMatch(passphrase, options.Dictionary) if err != nil { return result, err } if match { result.Ok = false result.DictionaryMatch = true } } return result, nil } /* * we compress with Flate level 9 (max) and see if the result is * smaller than the password, in which case it could be compressed and * contains repeating characters; OR it is larger than the password, * in which case it could NOT be compressed, which is what we want. */ func getCompression(passphrase []byte) (int, error) { var b bytes.Buffer flater, _ := flate.NewWriter(&b, 9) if _, err := flater.Write(passphrase); err != nil { return 0, fmt.Errorf("failed to write to flate writer: %w", err) } if err := flater.Flush(); err != nil { return 0, fmt.Errorf("failed to flush flate writer: %w", err) } if err := flater.Close(); err != nil { return 0, fmt.Errorf("failed to close flate writer: %w", err) } // use floats to avoid division by zero panic length := float32(len(passphrase)) compressed := float32(len(b.Bytes())) if compressed >= length { return 0, nil } percent := 100 - (compressed / (length / 100)) return int(percent), nil } /* Return the entropy as bits/char, where char is a printable char in US-ASCII space. Returns error if a char is non-printable. */ func getEntropy(passphrase string) (float64, error) { var entropy float64 length := len(passphrase) wherechar := make([]int, MAX_CHARS) hist := make([]int, length) var histlen int for i := 0; i < MAX_CHARS; i++ { wherechar[i] = -1 } for _, char := range []byte(passphrase) { if char < ascii_base || char > 126 { return 0, fmt.Errorf("non-printable ASCII character encountered: %c", char) } if wherechar[char-ascii_base] == -1 { wherechar[char-ascii_base] = histlen histlen++ } hist[wherechar[char-ascii_base]]++ } for i := 0; i < histlen; i++ { diff := float64(hist[i]) / float64(length) entropy -= diff * math.Log2(diff) } return entropy, nil } /* * Return character distribution in US-ASCII space */ func getDistribution(passphrase string) float64 { hash := make([]int, MAX_CHARS) var chars float64 for _, char := range []byte(passphrase) { hash[char-ascii_base]++ } for i := 0; i < MAX_CHARS; i++ { if hash[i] > 0 { chars++ } } return chars / (float64(MAX_CHARS) / 100) } /* * Return true if password can be found in given dictionary. This has * to be supplied by the user, we do NOT ship with a dictionary! */ func getDictMatch(passphrase string, dict *Dictionary) (bool, error) { if len(dict.Words) < MIN_DICT_LEN { return false, fmt.Errorf("provided dictionary is too small") } lcpass := strings.ToLower(passphrase) if dict.Submatch { for _, word := range dict.Words { if strings.Contains(strings.ToLower(word), lcpass) { return true, nil } } } else { for _, word := range dict.Words { if lcpass == strings.ToLower(word) { return true, nil } } } return false, nil }