mirror of
https://github.com/wneessen/apg-go.git
synced 2024-11-22 05:40:51 +01:00
Winni Neessen
3c437e7238
This commit introduces a new mobile grouping feature in our password generator package. Furthermore, a new unit test was added to the 'random_test.go' file to validate this functionality. This change allows users to better handle passwords on mobile devices by altering their formation.
374 lines
11 KiB
Go
374 lines
11 KiB
Go
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
|
|
//
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package apg
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
// 7 bits to represent a letter index
|
|
letterIdxBits = 7
|
|
// All 1-bits, as many as letterIdxBits
|
|
letterIdxMask = 1<<letterIdxBits - 1
|
|
// # of letter indices fitting in 63 bits)
|
|
letterIdxMax = 63 / letterIdxBits
|
|
)
|
|
|
|
// maxInt32 is the maximum positive value for a int32 number type
|
|
const maxInt32 = 2147483647
|
|
|
|
var (
|
|
// ErrInvalidLength is returned if the provided maximum number is equal or less than zero
|
|
ErrInvalidLength = errors.New("provided length value cannot be zero or less")
|
|
// ErrLengthMismatch is returned if the number of generated bytes does not match the expected length
|
|
ErrLengthMismatch = errors.New("number of generated random bytes does not match the expected length")
|
|
// ErrInvalidCharRange is returned if the given range of characters is not valid
|
|
ErrInvalidCharRange = errors.New("provided character range is not valid or empty")
|
|
)
|
|
|
|
// CoinFlip performs a simple coinflip based on the rand library and returns 1 or 0
|
|
func (g *Generator) CoinFlip() int64 {
|
|
coinFlip, _ := g.RandNum(2)
|
|
return coinFlip
|
|
}
|
|
|
|
// CoinFlipBool performs a simple coinflip based on the rand library and returns true or false
|
|
func (g *Generator) CoinFlipBool() bool {
|
|
return g.CoinFlip() == 1
|
|
}
|
|
|
|
// Generate generates a password based on all the different config flags and returns
|
|
// it as string type. If the generation fails, an error will be thrown
|
|
func (g *Generator) Generate() (string, error) {
|
|
switch g.config.Algorithm {
|
|
case AlgoPronounceable:
|
|
return g.generatePronounceable()
|
|
case AlgoCoinFlip:
|
|
return g.generateCoinFlip()
|
|
case AlgoRandom:
|
|
return g.generateRandom()
|
|
case AlgoBinary:
|
|
return g.generateBinary()
|
|
case AlgoUnsupported:
|
|
return "", fmt.Errorf("unsupported algorithm")
|
|
default:
|
|
return "", fmt.Errorf("unsupported algorithm")
|
|
}
|
|
}
|
|
|
|
// GetCharRangeFromConfig checks the Mode from the Config and returns a
|
|
// list of all possible characters that are supported by these Mode
|
|
func (g *Generator) GetCharRangeFromConfig() string {
|
|
charRange := strings.Builder{}
|
|
if MaskHasMode(g.config.Mode, ModeLowerCase) {
|
|
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
|
|
case true:
|
|
charRange.WriteString(CharRangeAlphaLowerHuman)
|
|
default:
|
|
charRange.WriteString(CharRangeAlphaLower)
|
|
}
|
|
}
|
|
if MaskHasMode(g.config.Mode, ModeNumeric) {
|
|
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
|
|
case true:
|
|
charRange.WriteString(CharRangeNumericHuman)
|
|
default:
|
|
charRange.WriteString(CharRangeNumeric)
|
|
}
|
|
}
|
|
if MaskHasMode(g.config.Mode, ModeSpecial) {
|
|
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
|
|
case true:
|
|
charRange.WriteString(CharRangeSpecialHuman)
|
|
default:
|
|
charRange.WriteString(CharRangeSpecial)
|
|
}
|
|
}
|
|
if MaskHasMode(g.config.Mode, ModeUpperCase) {
|
|
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
|
|
case true:
|
|
charRange.WriteString(CharRangeAlphaUpperHuman)
|
|
default:
|
|
charRange.WriteString(CharRangeAlphaUpper)
|
|
}
|
|
}
|
|
if g.config.ExcludeChars != "" {
|
|
rex, err := regexp.Compile("[" + regexp.QuoteMeta(g.config.ExcludeChars) + "]")
|
|
if err == nil {
|
|
newRange := rex.ReplaceAllLiteralString(charRange.String(), "")
|
|
charRange.Reset()
|
|
charRange.WriteString(newRange)
|
|
} else {
|
|
_, _ = fmt.Fprintf(os.Stderr, "failed to exclude characters: %s\n", err)
|
|
}
|
|
}
|
|
return charRange.String()
|
|
}
|
|
|
|
// GetPasswordLength returns the password length based on the given config
|
|
// parameters
|
|
func (g *Generator) GetPasswordLength() (int64, error) {
|
|
if g.config.FixedLength > 0 {
|
|
return g.config.FixedLength, nil
|
|
}
|
|
minLength := g.config.MinLength
|
|
maxLength := g.config.MaxLength
|
|
if minLength > maxLength {
|
|
maxLength = minLength
|
|
}
|
|
diff := maxLength - minLength + 1
|
|
randNum, err := g.RandNum(diff)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
length := minLength + randNum
|
|
if length <= 0 {
|
|
return 1, nil
|
|
}
|
|
return length, nil
|
|
}
|
|
|
|
// RandomBytes returns a byte slice of random bytes with given length that got generated by
|
|
// the crypto/rand generator
|
|
func (g *Generator) RandomBytes(length int64) ([]byte, error) {
|
|
if length < 1 {
|
|
return nil, ErrInvalidLength
|
|
}
|
|
bytes := make([]byte, length)
|
|
numBytes, err := rand.Read(bytes)
|
|
if int64(numBytes) != length {
|
|
return nil, ErrLengthMismatch
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return bytes, nil
|
|
}
|
|
|
|
// RandNum generates a random, non-negative number with given maximum value
|
|
func (g *Generator) RandNum(max int64) (int64, error) {
|
|
if max < 1 {
|
|
return 0, ErrInvalidLength
|
|
}
|
|
max64 := big.NewInt(max)
|
|
randNum, err := rand.Int(rand.Reader, max64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("random number generation failed: %w", err)
|
|
}
|
|
return randNum.Int64(), nil
|
|
}
|
|
|
|
// RandomStringFromCharRange returns a random string of length l based of the range of characters given.
|
|
// The method makes use of the crypto/random package and therfore is
|
|
// cryptographically secure
|
|
func (g *Generator) RandomStringFromCharRange(length int64, charRange string) (string, error) {
|
|
if length < 1 {
|
|
return "", ErrInvalidLength
|
|
}
|
|
if len(charRange) < 1 {
|
|
return "", ErrInvalidCharRange
|
|
}
|
|
randString := strings.Builder{}
|
|
|
|
// As long as the length is smaller than the max. int32 value let's grow
|
|
// the string builder to the actual size, so we need less allocations
|
|
if length <= maxInt32 {
|
|
randString.Grow(int(length))
|
|
}
|
|
|
|
charRangeLength := len(charRange)
|
|
|
|
randPool := make([]byte, 8)
|
|
_, err := rand.Read(randPool)
|
|
if err != nil {
|
|
return randString.String(), err
|
|
}
|
|
for idx, char, rest := length-1, binary.BigEndian.Uint64(randPool), letterIdxMax; idx >= 0; {
|
|
if rest == 0 {
|
|
_, err = rand.Read(randPool)
|
|
if err != nil {
|
|
return randString.String(), err
|
|
}
|
|
char, rest = binary.BigEndian.Uint64(randPool), letterIdxMax
|
|
}
|
|
if i := int(char & letterIdxMask); i < charRangeLength {
|
|
randString.WriteByte(charRange[i])
|
|
idx--
|
|
}
|
|
char >>= letterIdxBits
|
|
rest--
|
|
}
|
|
|
|
return randString.String(), nil
|
|
}
|
|
|
|
// checkMinimumRequirements checks if a password meets the minimum requirements specified in the
|
|
// generator's configuration. It returns true if the password meets the requirements, otherwise it
|
|
// returns false.
|
|
//
|
|
// The minimum requirements for each character type (lowercase, numeric, special, uppercase) are
|
|
// checked independently. For each character type, the corresponding character range is determined
|
|
// based on the generator's configuration. The password is then checked for the presence of each
|
|
// character in the character range, and a count is maintained.
|
|
func (g *Generator) checkMinimumRequirements(password string) bool {
|
|
ok := true
|
|
if g.config.MinLowerCase > 0 {
|
|
var charRange string
|
|
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
|
|
case true:
|
|
charRange = CharRangeAlphaLowerHuman
|
|
default:
|
|
charRange = CharRangeAlphaLower
|
|
}
|
|
|
|
matchesMinimumAmount(charRange, password, g.config.MinLowerCase, &ok)
|
|
}
|
|
if g.config.MinNumeric > 0 {
|
|
var charRange string
|
|
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
|
|
case true:
|
|
charRange = CharRangeNumericHuman
|
|
default:
|
|
charRange = CharRangeNumeric
|
|
}
|
|
|
|
matchesMinimumAmount(charRange, password, g.config.MinNumeric, &ok)
|
|
}
|
|
if g.config.MinSpecial > 0 {
|
|
var charRange string
|
|
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
|
|
case true:
|
|
charRange = CharRangeSpecialHuman
|
|
default:
|
|
charRange = CharRangeSpecial
|
|
}
|
|
|
|
matchesMinimumAmount(charRange, password, g.config.MinSpecial, &ok)
|
|
}
|
|
if g.config.MinUpperCase > 0 {
|
|
var charRange string
|
|
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
|
|
case true:
|
|
charRange = CharRangeAlphaUpperHuman
|
|
default:
|
|
charRange = CharRangeAlphaUpper
|
|
}
|
|
|
|
matchesMinimumAmount(charRange, password, g.config.MinUpperCase, &ok)
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// generateCoinFlip is executed when Generate() is called with Algorithm set
|
|
// to AlgoCoinFlip
|
|
func (g *Generator) generateCoinFlip() (string, error) {
|
|
if g.CoinFlipBool() {
|
|
return "Heads", nil
|
|
}
|
|
return "Tails", nil
|
|
}
|
|
|
|
// generatePronounceable is executed when Generate() is called with Algorithm set
|
|
// to AlgoPronounceable
|
|
func (g *Generator) generatePronounceable() (string, error) {
|
|
var password string
|
|
g.syllables = make([]string, 0)
|
|
|
|
length, err := g.GetPasswordLength()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to calculate password length: %w", err)
|
|
}
|
|
|
|
characterSet := KoremutakeSyllables
|
|
characterSet = append(characterSet, strings.Split(CharRangeNumericHuman, "")...)
|
|
characterSet = append(characterSet, strings.Split(CharRangeSpecialHuman, "")...)
|
|
characterSetLength := len(characterSet)
|
|
for int64(len(password)) < length {
|
|
randNum, err := g.RandNum(int64(characterSetLength))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate a random number for Koremutake syllable generation: %w",
|
|
err)
|
|
}
|
|
nextSyllable := characterSet[randNum]
|
|
if g.CoinFlipBool() {
|
|
syllableLength := len(nextSyllable)
|
|
characterPosition, err := g.RandNum(int64(syllableLength))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate a random number for Koremutake syllable generation: %w",
|
|
err)
|
|
}
|
|
randomChar := string(nextSyllable[characterPosition])
|
|
nextSyllable = strings.ReplaceAll(nextSyllable, randomChar, strings.ToUpper(randomChar))
|
|
}
|
|
password += nextSyllable
|
|
g.syllables = append(g.syllables, nextSyllable)
|
|
}
|
|
|
|
return password, nil
|
|
}
|
|
|
|
// generateBinary is executed when Generate() is called with Algorithm set
|
|
// to AlgoBinary
|
|
func (g *Generator) generateBinary() (string, error) {
|
|
length := DefaultBinarySize
|
|
if g.config.FixedLength > 0 {
|
|
length = g.config.FixedLength
|
|
}
|
|
randBytes := make([]byte, length)
|
|
_, err := rand.Read(randBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
|
}
|
|
if g.config.BinaryHexMode {
|
|
return fmt.Sprintf("%x", randBytes), nil
|
|
}
|
|
return string(randBytes), nil
|
|
}
|
|
|
|
// generateRandom is executed when Generate() is called with Algorithm set
|
|
// to AlgoRandom
|
|
func (g *Generator) generateRandom() (string, error) {
|
|
length, err := g.GetPasswordLength()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to calculate password length: %w", err)
|
|
}
|
|
charRange := g.GetCharRangeFromConfig()
|
|
var password string
|
|
var ok bool
|
|
for !ok {
|
|
password, err = g.RandomStringFromCharRange(length, charRange)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
ok = g.checkMinimumRequirements(password)
|
|
}
|
|
|
|
if g.config.MobileGrouping {
|
|
return GroupCharsForMobile(password), nil
|
|
}
|
|
return password, nil
|
|
}
|
|
|
|
// matchesMinimumAmount checks if the number of occurrences of characters in
|
|
// charRange in the password is less than minAmount and updates the
|
|
// value of ok accordingly.
|
|
func matchesMinimumAmount(charRange, password string, minAmount int64, ok *bool) {
|
|
count := 0
|
|
for _, char := range charRange {
|
|
count += strings.Count(password, string(char))
|
|
}
|
|
if int64(count) < minAmount {
|
|
*ok = false
|
|
}
|
|
}
|