apg-go/random.go
Winni Neessen 3c437e7238
Add mobile-friendly password grouping feature
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.
2024-03-25 11:31:41 +01:00

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