diff --git a/cmd/generate.go b/cmd/generate.go index ce34bd9..128f25a 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -17,6 +17,7 @@ package cmd import ( "fmt" + "os" "github.com/danielkosgei/detergen/internal/generator" "github.com/spf13/cobra" @@ -29,11 +30,13 @@ var length int var generateCmd = &cobra.Command{ Use: "generate [baseword]", Short: "Generate a deterministic password", - Long: `Generate a deterministic 8-character password from a base word. + Long: `Generate a deterministic password from a base word using Argon2 hashing. The same base word and salt will always produce the same password. Use different salts (like site names) to create unique passwords for different services. +Default length is 12 characters. Minimum length is 8 + Example: dg generate mypassword --salt facebook dg generate mypassword --salt twitter -l 16 @@ -51,7 +54,7 @@ Example: // enforce minimum length if length < 8 { length = 8 - fmt.Println("Note: Minimum password length is 8 characters for your security.") + fmt.Println("Note: Minimum password length is 8 characters for security.") } // enforce maximum length @@ -60,7 +63,12 @@ Example: fmt.Println("Note: Maximum password length is 128 characters.") } - password := generator.Generate(baseWord, salt, length) + password, err := generator.Generate(baseWord, salt, length) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Println(password) }, } diff --git a/internal/generator/generator.go b/internal/generator/generator.go index f658bd8..5ad85e4 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -2,21 +2,44 @@ package generator import ( "encoding/binary" + "errors" "golang.org/x/crypto/argon2" ) // create a deterministic password of specified length -func Generate(baseWord string, salt string, length int) string { +func Generate(baseWord string, salt string, length int) (string, error) { + if baseWord == "" { + return "", errors.New("base word cannot be empty") + } + + if len(baseWord) > 1000 { + return "", errors.New("base word too long(max 1000 characters)") + } + + if len(salt) > 1000 { + return "", errors.New("salt too long (max 1000 characters)") + } + + if length < 4 { + return "", errors.New("password length must be at least 4 characters") + } + + if length > 129 { + return "", errors.New("password length cannot exceed 128 characters") + } + // combine base word with option salt input := baseWord if salt != "" { input = baseWord + salt } - // use Argon2id for hashing - // parameters: time=3, memory=256MB, threads=4 // These are OWASP recommended parameters for password hashing + // - time=3: Number of iterations to balance security and performance + // - memory=256MB: Memory cost to prevent GPU/ASIC attacks + // - threads=4: Degree of parallelism + // - output=64 bytes: Provides ample entropy for long passwords hash := argon2.IDKey([]byte(input), []byte("detergen-v1"), 3, 256*1024, 4, 64) // character sets @@ -59,7 +82,7 @@ func Generate(baseWord string, salt string, length int) string { password[i], password[j] = password[j], password[i] } - return string(password) + return string(password), nil } // use rejection sampling to avoid modulo bias diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 9f34366..fe9c442 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -7,7 +7,10 @@ import ( // test basic generation func TestGenerate(t *testing.T) { - password := Generate("myword", "", 12) + password, err := Generate("myword", "", 12) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } if len(password) != 12 { t.Errorf("Expected password length 12, got %d", len(password)) @@ -18,8 +21,12 @@ func TestGenerate(t *testing.T) { // test that the same input produces same output func TestGenerateDeterministic(t *testing.T) { - password1 := Generate("test", "facebook", 8) - password2 := Generate("test", "facebook", 8) + password1, err1 := Generate("test", "facebook", 8) + password2, err2 := Generate("test", "facebook", 8) + + if err1 != nil || err2 != nil { + t.Fatalf("Unexpected errors: %v, %v", err1, err2) + } if password1 != password2 { t.Errorf("Expected same password for same input, got %s and %s", password1, password2) @@ -30,8 +37,12 @@ func TestGenerateDeterministic(t *testing.T) { // test that different salts produce different passowrds func TestGenerateDifferentSalts(t *testing.T) { - password1 := Generate("myword", "facebook", 8) - password2 := Generate("myword", "twitter", 8) + password1, err1 := Generate("myword", "facebook", 8) + password2, err2 := Generate("myword", "twitter", 8) + + if err1 != nil || err2 != nil { + t.Fatalf("Unexpected errors: %v, %v", err1, err2) + } if password1 == password2 { t.Errorf("Expected different passwords for different salts, both got %s", password1) @@ -42,7 +53,10 @@ func TestGenerateDifferentSalts(t *testing.T) { } func TestGenerateCharacterTypes(t *testing.T) { - password := Generate("test", "site", 8) + password, err := Generate("test", "site", 8) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } hasUpper := false hasLower := false @@ -82,9 +96,13 @@ func TestGenerateCharacterTypes(t *testing.T) { // test different password lengths func TestGenerateCustomLength(t *testing.T) { - password12 := Generate("test", "site", 12) - password16 := Generate("test", "site", 16) - password20 := Generate("test", "site", 20) + password12, err1 := Generate("test", "site", 12) + password16, err2 := Generate("test", "site", 16) + password20, err3 := Generate("test", "site", 20) + + if err1 != nil || err2 != nil || err3 != nil { + t.Fatalf("Unexpected errors: %v, %v, %v", err1, err2, err3) + } if len(password12) != 12 { t.Errorf("Expected length 12, got %d", len(password12)) @@ -100,3 +118,70 @@ func TestGenerateCustomLength(t *testing.T) { t.Logf("16-char password: %s", password16) t.Logf("20-char password: %s", password20) } + +// test that minimum length is enforced +func TestGenerateMinimumLength(t *testing.T) { + password, err := Generate("test", "site", 5) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(password) != 8 { + t.Errorf("Expected minimum length 8, got %d", len(password)) + } + + t.Logf("Minimum length password: %s", password) +} + +// test that empty base word returns error +func TestGenerateEmptyBaseWord(t *testing.T) { + _, err := Generate("", "salt", 12) + + if err == nil { + t.Error("Expected error for empty base word") + } + + t.Logf("Correctly rejected empty base word: %v", err) +} + +func TestGenerateTooLongInputs(t *testing.T) { + // test that excessively long inputs are rejected + longWord := string(make([]byte, 1001)) + _, err := Generate(longWord, "", 12) + + if err == nil { + t.Error("Expected error for too long base word") + } + + t.Logf("Correctly rejected long base word: %v", err) + + // test long salt + longSalt := string(make([]byte, 1001)) + _, err = Generate("test", longSalt, 12) + + if err == nil { + t.Error("Expected error for too long salt") + } + + t.Logf("Correctly rejected long salt: %v", err) +} + +// test that invalid lengths are rejected +func TestGenerateInvalidLength(t *testing.T) { + _, err := Generate("test", "salt", 3) + + if err == nil { + t.Error("Expected error for length < 4") + } + + t.Logf("Correctly rejected invalid length: %v", err) + + _, err = Generate("test", "salt", 200) + + if err == nil { + t.Error("Expected error for length > 128") + } + + t.Logf("Correctly rejected excessive length: %v", err) +}