diff --git a/cmd/generate.go b/cmd/generate.go index ea0c620..ce34bd9 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -36,16 +36,30 @@ Use different salts (like site names) to create unique passwords for different s Example: dg generate mypassword --salt facebook - dg generate mypassword --salt twitter`, + dg generate mypassword --salt twitter -l 16 + dg generate mypassword`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { baseWord := args[0] + if salt == "" { + fmt.Println("Warning: No salt provided. Using base word only.") + fmt.Println("Consider using --salt for site-specific passwords.") + fmt.Println() + } + // enforce minimum length if length < 8 { length = 8 fmt.Println("Note: Minimum password length is 8 characters for your security.") } + + // enforce maximum length + if length > 128 { + length = 128 + fmt.Println("Note: Maximum password length is 128 characters.") + } + password := generator.Generate(baseWord, salt, length) fmt.Println(password) }, diff --git a/cmd/root.go b/cmd/root.go index 7b2868f..82681dd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,14 +23,18 @@ import ( // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ - Use: "detergen", - Short: "A brief description of your application", - Long: `A longer description that spans multiple lines and likely contains -examples and usage of using your application. For example: - -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, + Use: "dg", + Short: "Deterministic password generator", + Long: `detergen (dg) is a deterministic password generator that creates secure, +reproducible passwords using Argon2 hashing. + +The same base word and salt will always produce the same password, allowing +you to recreate passwords without storing them. Use different salts (like +site names) to generate unique passwords for different services. + +Example: + dg generate myword -s facebook + dg generate myword -s twitter -l 16`, // Uncomment the following line if your bare application // has an action associated with it: // Run: func(cmd *cobra.Command, args []string) { }, diff --git a/internal/generator/generator.go b/internal/generator/generator.go index a2340a5..f658bd8 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -1,54 +1,108 @@ package generator import ( - "encoding/hex" + "encoding/binary" "golang.org/x/crypto/argon2" ) -// create a deterministic 8-character password with optional salt +// create a deterministic password of specified length func Generate(baseWord string, salt string, length int) string { + // combine base word with option salt input := baseWord if salt != "" { input = baseWord + salt } - // use Argon2id, parameters: time=3, memory=256mb, threads=4 - hash := argon2.IDKey([]byte(input), []byte("detergen-v1"), 3, 256*1024, 4, 32) - hashString := hex.EncodeToString(hash) + // use Argon2id for hashing + // parameters: time=3, memory=256MB, threads=4 + // These are OWASP recommended parameters for password hashing + hash := argon2.IDKey([]byte(input), []byte("detergen-v1"), 3, 256*1024, 4, 64) + // character sets uppercase := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" lowercase := "abcdefghijklmnopqrstuvwxyz" numbers := "0123456789" special := "!@#$%^&*" - if length < 4 { - length = 4 + // ensure min length + if length < 8 { + length = 8 } - password := make([]byte, length) + // ensure max length + if length > 128 { + length = 128 + } /* we need at least one of each type so we need to guarantee that * first then fill the remaining positions with mixed characters */ + password := make([]byte, length) + // guaranteed characters, one of each type - password[0] = uppercase[int(hashString[0])%len(uppercase)] - password[1] = lowercase[int(hashString[1])%len(lowercase)] - password[2] = numbers[int(hashString[2])%len(numbers)] - password[3] = special[int(hashString[3])%len(special)] + password[0] = selectChar(hash, 0, uppercase) + password[1] = selectChar(hash, 1, lowercase) + password[2] = selectChar(hash, 2, numbers) + password[3] = selectChar(hash, 3, special) // fill remaining positions with mixed characters allChars := uppercase + lowercase + numbers + special for i := 4; i < length; i++ { - password[i] = allChars[int(hashString[i%len(hashString)])%len(allChars)] + password[i] = selectChar(hash, i, allChars) } - // we shuffle the password with the hash for deterministic shuffling + // shuffle the password using the hash for deterministic shuffling for i := len(password) - 1; i > 0; i-- { - // use different parts of the hash for shuffling - j := int(hashString[(8+i)%len(hashString)]) % (i + 1) + // use hash bytes for shuffling with unbiased selection + j := int(selectIndex(hash, length+i, i+1)) password[i], password[j] = password[j], password[i] } return string(password) } + +// use rejection sampling to avoid modulo bias +func selectChar(hash []byte, offset int, charset string) byte { + charsetLen := len(charset) + + // 4 bytes from hash to create a uint32 + hashOffset := (offset * 4) % len(hash) + if hashOffset+4 > len(hash) { + hashOffset = len(hash) - 4 + } + + value := binary.BigEndian.Uint32(hash[hashOffset : hashOffset+4]) + + // calculate the largest multiple of charsetLen that fits in uint32 + maxValid := (0xFFFFFFFF / uint32(charsetLen)) * uint32(charsetLen) + + // if value is in the biased range, fold it back (deterministic rejection) + if value >= maxValid { + value = value % maxValid + } + + return charset[value%uint32(charsetLen)] +} + +// returns an unbiased index in range [0, max] +func selectIndex(hash []byte, offset int, max int) uint32 { + if max <= 1 { + return 0 + } + + hashOffset := (offset * 4) % len(hash) + if hashOffset+4 > len(hash) { + hashOffset = len(hash) - 4 + } + + value := binary.BigEndian.Uint32(hash[hashOffset : hashOffset+4]) + + maxValid := (0xFFFFFFFF / uint32(max)) * uint32(max) + + if value >= maxValid { + value = value % maxValid + } + + return value % uint32(max) +}