Files
GoNetKit/passwordgenerator/generator.go

626 lines
17 KiB
Go

package passwordgenerator
import (
"bufio"
"crypto/rand"
"fmt"
"math/big"
"net/http"
"os"
"strings"
"sync"
"time"
)
type Config struct {
Length int `json:"length"`
IncludeUpper bool `json:"includeUpper"`
IncludeLower bool `json:"includeLower"`
NumberCount int `json:"numberCount"`
SpecialChars string `json:"specialChars"`
MinSpecialChars int `json:"minSpecialChars"`
NoConsecutive bool `json:"noConsecutive"`
UsePassphrase bool `json:"usePassphrase"`
WordCount int `json:"wordCount"`
NumberPosition string `json:"numberPosition"` // "start", "end", "each"
PassphraseUseNumbers bool `json:"passphraseUseNumbers"`
PassphraseUseSpecial bool `json:"passphraseUseSpecial"`
}
// Fallback word list in case online fetch fails
var fallbackWordList = []string{
"apple", "brave", "chair", "dance", "eagle", "flame", "grape", "happy", "island", "jungle",
"kite", "lemon", "magic", "night", "ocean", "piano", "quiet", "river", "storm", "tiger",
"under", "voice", "water", "young", "zebra", "beach", "cloud", "dream", "fresh", "glass",
"heart", "light", "money", "paper", "quick", "royal", "smile", "table", "violet", "world",
"bright", "castle", "gentle", "honest", "knight", "marble", "orange", "purple", "silver", "yellow",
"ancient", "crystal", "distant", "emerald", "fantasy", "harmony", "journey", "mystery", "outdoor", "perfect",
"rainbow", "serenity", "thunder", "universe", "victory", "whisper", "amazing", "balance", "courage", "dignity",
"elegant", "freedom", "golden", "holiday", "inspire", "justice", "kindness", "library", "mountain", "natural",
"peaceful", "quality", "respect", "sunshine", "triumph", "unique", "wonderful", "adventure", "beautiful", "creative",
"delicate", "exciting", "friendly", "generous", "hilarious", "incredible", "joyful", "lovely", "magnificent", "optimistic",
}
// Global word list cache
var (
wordList []string
wordListMux sync.RWMutex
lastFetch time.Time
fetchTimeout = 10 * time.Second
cacheExpiry = 24 * time.Hour // Cache for 24 hours
wordListFile = "wordlist.txt" // Local file path
)
// InitWordList initializes the word list cache. Call this at app startup.
func InitWordList() {
go func() {
// Fetch word list in background to warm up cache
getWordList()
}()
}
// GetWordListInfo returns information about the current word list
func GetWordListInfo() (count int, source string, lastUpdate time.Time) {
wordListMux.RLock()
defer wordListMux.RUnlock()
count = len(wordList)
if count == 0 {
count = len(fallbackWordList)
source = "fallback"
return
}
// Check if we have a local file
if _, err := os.Stat(wordListFile); err == nil {
fileTime := getFileModTime(wordListFile)
if time.Since(fileTime) > cacheExpiry {
source = "local file (expired)"
} else if len(wordList) > len(fallbackWordList) {
source = "local file (from MIT)"
} else {
source = "local file"
}
return count, source, fileTime
}
if time.Since(lastFetch) > cacheExpiry {
source = "cached (expired)"
} else if len(wordList) > len(fallbackWordList) {
source = "MIT online"
} else {
source = "fallback"
}
return count, source, lastFetch
}
// getWordList returns the current word list, fetching from online source if needed
func getWordList() []string {
wordListMux.RLock()
// Check if we have a cached word list that's still valid
if len(wordList) > 0 && time.Since(lastFetch) < cacheExpiry {
defer wordListMux.RUnlock()
return wordList
}
wordListMux.RUnlock()
// Need to fetch or refresh
wordListMux.Lock()
defer wordListMux.Unlock()
// Double-check in case another goroutine already fetched
if len(wordList) > 0 && time.Since(lastFetch) < cacheExpiry {
return wordList
}
// First try to load from local file
if localWords, err := loadWordListFromFile(); err == nil && len(localWords) >= 100 {
wordList = localWords
lastFetch = getFileModTime(wordListFile)
return wordList
}
// If local file doesn't exist or is invalid, try to fetch from MIT
newWordList, err := fetchWordListFromMIT()
if err != nil || len(newWordList) < 100 { // Sanity check
// Use fallback if fetch failed or list is too small
if len(wordList) == 0 {
wordList = make([]string, len(fallbackWordList))
copy(wordList, fallbackWordList)
}
return wordList
}
// Save downloaded word list to local file
saveWordListToFile(newWordList)
wordList = newWordList
lastFetch = time.Now()
return wordList
}
// loadWordListFromFile loads the word list from local file
func loadWordListFromFile() ([]string, error) {
file, err := os.Open(wordListFile)
if err != nil {
return nil, err
}
defer file.Close()
var words []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
word := strings.TrimSpace(scanner.Text())
if len(word) > 0 && len(word) <= 15 {
words = append(words, word)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return words, nil
}
// saveWordListToFile saves the word list to local file
func saveWordListToFile(words []string) error {
file, err := os.Create(wordListFile)
if err != nil {
return err
}
defer file.Close()
for _, word := range words {
if _, err := fmt.Fprintln(file, word); err != nil {
return err
}
}
return nil
}
// getFileModTime returns the modification time of a file
func getFileModTime(filename string) time.Time {
if info, err := os.Stat(filename); err == nil {
return info.ModTime()
}
return time.Time{}
}
// fetchWordListFromMIT fetches the word list from MIT's server
// fetchWordListFromMIT fetches the word list from MIT's server
func fetchWordListFromMIT() ([]string, error) {
client := &http.Client{
Timeout: fetchTimeout,
}
resp, err := client.Get("https://www.mit.edu/~ecprice/wordlist.10000")
if err != nil {
return nil, fmt.Errorf("failed to fetch word list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var words []string
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
word := strings.TrimSpace(scanner.Text())
if len(word) > 0 && len(word) <= 15 { // Filter out empty lines and very long words
words = append(words, word)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading word list: %w", err)
}
if len(words) < 100 {
return nil, fmt.Errorf("word list too small: got %d words", len(words))
}
return words, nil
}
func DefaultConfig() Config {
return Config{
Length: 12,
IncludeUpper: true,
IncludeLower: true,
NumberCount: 1,
SpecialChars: "!@#$%&*-_=+.",
MinSpecialChars: 3,
NoConsecutive: true,
UsePassphrase: true, // Default to passphrase
WordCount: 3,
NumberPosition: "end",
PassphraseUseNumbers: true,
PassphraseUseSpecial: true,
}
}
func GeneratePassword(config Config) (string, error) {
if config.UsePassphrase {
return generatePassphrase(config)
}
return generateRandomPassword(config)
}
func generateRandomPassword(config Config) (string, error) {
if config.Length < 1 {
return "", fmt.Errorf("password length must be at least 1")
}
var charset string
var required []string
// Build character set
if config.IncludeLower {
charset += "abcdefghijklmnopqrstuvwxyz"
required = append(required, "abcdefghijklmnopqrstuvwxyz")
}
if config.IncludeUpper {
charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
required = append(required, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
}
if config.NumberCount > 0 {
charset += "0123456789"
required = append(required, "0123456789")
}
if len(config.SpecialChars) > 0 {
charset += config.SpecialChars
required = append(required, config.SpecialChars)
}
if len(charset) == 0 {
return "", fmt.Errorf("no character types selected")
}
// Validate that minimum special characters doesn't exceed password length
totalRequired := config.NumberCount + config.MinSpecialChars
if config.IncludeLower {
totalRequired++
}
if config.IncludeUpper {
totalRequired++
}
if totalRequired > config.Length {
return "", fmt.Errorf("password length too short for required character counts")
}
password := make([]byte, config.Length)
// Ensure at least one character from each required set
usedPositions := make(map[int]bool)
// First, place required numbers
numbersPlaced := 0
for numbersPlaced < config.NumberCount && numbersPlaced < config.Length {
pos, err := rand.Int(rand.Reader, big.NewInt(int64(config.Length)))
if err != nil {
return "", err
}
posInt := int(pos.Int64())
if !usedPositions[posInt] {
char, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", err
}
password[posInt] = byte('0' + char.Int64())
usedPositions[posInt] = true
numbersPlaced++
}
}
// Place required special characters
specialCharsPlaced := 0
if config.MinSpecialChars > 0 && len(config.SpecialChars) > 0 {
attempts := 0
maxAttempts := config.Length * 10 // Prevent infinite loops
for specialCharsPlaced < config.MinSpecialChars && attempts < maxAttempts {
pos, err := rand.Int(rand.Reader, big.NewInt(int64(config.Length)))
if err != nil {
return "", err
}
posInt := int(pos.Int64())
if !usedPositions[posInt] {
char, err := rand.Int(rand.Reader, big.NewInt(int64(len(config.SpecialChars))))
if err != nil {
return "", err
}
password[posInt] = config.SpecialChars[char.Int64()]
usedPositions[posInt] = true
specialCharsPlaced++
}
attempts++
}
// If we couldn't place enough special characters due to bad luck, force placement
if specialCharsPlaced < config.MinSpecialChars {
for i := 0; i < config.Length && specialCharsPlaced < config.MinSpecialChars; i++ {
if !usedPositions[i] {
char, err := rand.Int(rand.Reader, big.NewInt(int64(len(config.SpecialChars))))
if err != nil {
return "", err
}
password[i] = config.SpecialChars[char.Int64()]
usedPositions[i] = true
specialCharsPlaced++
}
}
}
}
// Then place at least one from each other required character set (excluding numbers and special chars already handled)
for _, reqSet := range required {
if reqSet == "0123456789" || reqSet == config.SpecialChars {
continue // Already handled numbers and special chars
}
placed := false
attempts := 0
for !placed && attempts < config.Length*2 {
pos, err := rand.Int(rand.Reader, big.NewInt(int64(config.Length)))
if err != nil {
return "", err
}
posInt := int(pos.Int64())
if !usedPositions[posInt] {
char, err := rand.Int(rand.Reader, big.NewInt(int64(len(reqSet))))
if err != nil {
return "", err
}
password[posInt] = reqSet[char.Int64()]
usedPositions[posInt] = true
placed = true
}
attempts++
}
}
// Fill remaining positions
for i := 0; i < config.Length; i++ {
if !usedPositions[i] {
char, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return "", err
}
password[i] = charset[char.Int64()]
}
}
// Handle no consecutive characters requirement
finalPassword := string(password)
if config.NoConsecutive {
finalPassword, err := ensureNoConsecutive(string(password), charset)
if err != nil {
return "", err
}
// After ensureNoConsecutive, we need to verify minimum requirements are still met
// and fix them if necessary
if config.MinSpecialChars > 0 && len(config.SpecialChars) > 0 {
// Count current special characters
currentSpecialCount := 0
for _, char := range finalPassword {
if strings.ContainsRune(config.SpecialChars, char) {
currentSpecialCount++
}
}
// If we don't have enough special characters, replace some non-special characters
if currentSpecialCount < config.MinSpecialChars {
passwordRunes := []rune(finalPassword)
needed := config.MinSpecialChars - currentSpecialCount
for i := 0; i < len(passwordRunes) && needed > 0; i++ {
// Find non-special characters that can be replaced
if !strings.ContainsRune(config.SpecialChars, passwordRunes[i]) {
// Make sure this replacement won't create consecutive characters
specialChar, err := rand.Int(rand.Reader, big.NewInt(int64(len(config.SpecialChars))))
if err != nil {
return "", err
}
newChar := rune(config.SpecialChars[specialChar.Int64()])
// Check if this would create consecutive characters
wouldCreateConsecutive := false
if i > 0 && passwordRunes[i-1] == newChar {
wouldCreateConsecutive = true
}
if i < len(passwordRunes)-1 && passwordRunes[i+1] == newChar {
wouldCreateConsecutive = true
}
if !wouldCreateConsecutive {
passwordRunes[i] = newChar
needed--
}
}
}
finalPassword = string(passwordRunes)
}
}
return finalPassword, nil
}
return finalPassword, nil
}
func generatePassphrase(config Config) (string, error) {
if config.WordCount < 1 {
return "", fmt.Errorf("word count must be at least 1")
}
// Get current word list (online or fallback)
currentWordList := getWordList()
words := make([]string, config.WordCount)
for i := 0; i < config.WordCount; i++ {
wordIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(currentWordList))))
if err != nil {
return "", err
}
word := currentWordList[wordIndex.Int64()]
// Capitalize first letter if upper case is enabled
if config.IncludeUpper {
word = strings.Title(word)
}
words[i] = word
}
// Generate numbers if enabled and needed
var numbers []string
if config.PassphraseUseNumbers && config.NumberCount > 0 {
for i := 0; i < config.NumberCount; i++ {
num, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", err
}
numbers = append(numbers, fmt.Sprintf("%d", num.Int64()))
}
}
// Helper function to get random separator
getSeparator := func() string {
if config.PassphraseUseSpecial && len(config.SpecialChars) > 0 {
sepIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(config.SpecialChars))))
if err != nil {
return "" // fallback to no separator
}
return string(config.SpecialChars[sepIndex.Int64()])
}
return ""
}
// Default separator for non-random cases
defaultSeparator := ""
if config.PassphraseUseSpecial && len(config.SpecialChars) > 0 {
defaultSeparator = "-"
}
// Combine based on number position
var result string
if len(numbers) == 0 {
// No numbers, join words with separators only if special chars are enabled
if config.PassphraseUseSpecial && len(config.SpecialChars) > 0 {
var parts []string
for i, word := range words {
parts = append(parts, word)
if i < len(words)-1 { // Don't add separator after last word
parts = append(parts, getSeparator())
}
}
result = strings.Join(parts, "")
} else {
// No special characters, just concatenate words without separators
result = strings.Join(words, "")
}
} else {
switch config.NumberPosition {
case "start":
numberStr := strings.Join(numbers, "")
if config.PassphraseUseSpecial && len(config.SpecialChars) > 0 {
var parts []string
parts = append(parts, numberStr)
for i, word := range words {
parts = append(parts, getSeparator(), word)
if i < len(words)-1 {
parts = append(parts, getSeparator())
}
}
result = strings.Join(parts, "")
} else {
result = numberStr + defaultSeparator + strings.Join(words, defaultSeparator)
}
case "end":
numberStr := strings.Join(numbers, "")
if config.PassphraseUseSpecial && len(config.SpecialChars) > 0 {
var parts []string
for i, word := range words {
parts = append(parts, word)
if i < len(words)-1 {
parts = append(parts, getSeparator())
}
}
parts = append(parts, getSeparator(), numberStr)
result = strings.Join(parts, "")
} else {
result = strings.Join(words, defaultSeparator) + defaultSeparator + numberStr
}
case "each":
var parts []string
for i, word := range words {
parts = append(parts, word)
// Add the specified number of digits after each word
for j := 0; j < config.NumberCount; j++ {
num, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", err
}
parts = append(parts, fmt.Sprintf("%d", num.Int64()))
}
// Add separator after each word+numbers (except last)
if i < len(words)-1 {
parts = append(parts, getSeparator())
}
}
result = strings.Join(parts, "")
default:
numberStr := strings.Join(numbers, "")
if config.PassphraseUseSpecial && len(config.SpecialChars) > 0 {
var parts []string
for i, word := range words {
parts = append(parts, word)
if i < len(words)-1 {
parts = append(parts, getSeparator())
}
}
parts = append(parts, getSeparator(), numberStr)
result = strings.Join(parts, "")
} else {
result = strings.Join(words, defaultSeparator) + defaultSeparator + numberStr
}
}
}
return result, nil
}
func ensureNoConsecutive(password, charset string) (string, error) {
passwordRunes := []rune(password)
maxAttempts := 1000
for attempt := 0; attempt < maxAttempts; attempt++ {
hasConsecutive := false
for i := 0; i < len(passwordRunes)-1; i++ {
if passwordRunes[i] == passwordRunes[i+1] {
// Replace the second character
newChar, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return "", err
}
passwordRunes[i+1] = rune(charset[newChar.Int64()])
hasConsecutive = true
}
}
if !hasConsecutive {
break
}
}
return string(passwordRunes), nil
}