Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package cmd

import (
"fmt"
"os"

"github.com/danielkosgei/detergen/internal/generator"
"github.com/spf13/cobra"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
},
}
Expand Down
31 changes: 27 additions & 4 deletions internal/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
103 changes: 94 additions & 9 deletions internal/generator/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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)
}