From 8619b048346fcb21ee0c99f341bdcc44d04c24fc Mon Sep 17 00:00:00 2001 From: Mohammed Al Sahaf Date: Fri, 5 Jun 2026 18:00:44 +0300 Subject: [PATCH] add caddyauth tests Signed-off-by: Mohammed Al Sahaf --- modules/caddyhttp/caddyauth/argon2id_test.go | 324 +++++++++++++++++++ modules/caddyhttp/caddyauth/bcrypt_test.go | 155 +++++++++ 2 files changed, 479 insertions(+) create mode 100644 modules/caddyhttp/caddyauth/argon2id_test.go create mode 100644 modules/caddyhttp/caddyauth/bcrypt_test.go diff --git a/modules/caddyhttp/caddyauth/argon2id_test.go b/modules/caddyhttp/caddyauth/argon2id_test.go new file mode 100644 index 000000000..73b38e159 --- /dev/null +++ b/modules/caddyhttp/caddyauth/argon2id_test.go @@ -0,0 +1,324 @@ +package caddyauth + +import ( + "strings" + "testing" + + "golang.org/x/crypto/argon2" +) + +func TestArgon2idHashCaddyModule(t *testing.T) { + a := Argon2idHash{} + info := a.CaddyModule() + if info.ID != "http.authentication.hashes.argon2id" { + t.Errorf("CaddyModule().ID = %v, want 'http.authentication.hashes.argon2id'", info.ID) + } +} + +func TestArgon2idDecodeHash(t *testing.T) { + tests := []struct { + name string + hash string + wantErr bool + wantErrStr string + }{ + { + name: "valid hash", + hash: "$argon2id$v=19$m=47104,t=1,p=1$P2nzckEdTZ3bxCiBCkRTyA$xQL3Z32eo5jKl7u5tcIsnEKObYiyNZQQf5/4sAau6Pg", + }, + { + name: "too few parts", + hash: "$argon2id$v=19$m=47104,t=1,p=1", + wantErr: true, + }, + { + name: "wrong variant", + hash: "$argon2i$v=19$m=47104,t=1,p=1$c29tZXNhbHQ$c29tZWtleQ", + wantErr: true, + wantErrStr: "unsupported variant", + }, + { + name: "invalid version", + hash: "$argon2id$v=abc$m=47104,t=1,p=1$c29tZXNhbHQ$c29tZWtleQ", + wantErr: true, + }, + { + name: "incompatible version", + hash: "$argon2id$v=18$m=47104,t=1,p=1$c29tZXNhbHQ$c29tZWtleQ", + wantErr: true, + }, + { + name: "invalid parameters - too few", + hash: "$argon2id$v=19$m=47104,t=1$c29tZXNhbHQ$c29tZWtleQ", + wantErr: true, + }, + { + name: "invalid memory parameter", + hash: "$argon2id$v=19$m=abc,t=1,p=1$c29tZXNhbHQ$c29tZWtleQ", + wantErr: true, + }, + { + name: "invalid iterations parameter", + hash: "$argon2id$v=19$m=47104,t=abc,p=1$c29tZXNhbHQ$c29tZWtleQ", + wantErr: true, + }, + { + name: "invalid parallelism parameter", + hash: "$argon2id$v=19$m=47104,t=1,p=abc$c29tZXNhbHQ$c29tZWtleQ", + wantErr: true, + }, + { + name: "invalid salt base64", + hash: "$argon2id$v=19$m=47104,t=1,p=1$!!!invalid!!!$c29tZWtleQ", + wantErr: true, + }, + { + name: "invalid key base64", + hash: "$argon2id$v=19$m=47104,t=1,p=1$c29tZXNhbHQ$!!!invalid!!!", + wantErr: true, + }, + { + name: "empty string", + hash: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + argHash, key, err := DecodeHash([]byte(tt.hash)) + if tt.wantErr { + if err == nil { + t.Error("DecodeHash() should return error") + } + if tt.wantErrStr != "" && !strings.Contains(err.Error(), tt.wantErrStr) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantErrStr) + } + return + } + if err != nil { + t.Fatalf("DecodeHash() error: %v", err) + } + if argHash == nil { + t.Fatal("DecodeHash() returned nil hash") + } + if key == nil { + t.Fatal("DecodeHash() returned nil key") + } + if argHash.time == 0 { + t.Error("decoded time is 0") + } + if argHash.memory == 0 { + t.Error("decoded memory is 0") + } + if argHash.threads == 0 { + t.Error("decoded threads is 0") + } + if len(argHash.salt) == 0 { + t.Error("decoded salt is empty") + } + }) + } +} + +func TestArgon2idDecodeHashParsesCorrectValues(t *testing.T) { + hash := "$argon2id$v=19$m=47104,t=1,p=1$P2nzckEdTZ3bxCiBCkRTyA$xQL3Z32eo5jKl7u5tcIsnEKObYiyNZQQf5/4sAau6Pg" + argHash, _, err := DecodeHash([]byte(hash)) + if err != nil { + t.Fatalf("DecodeHash() error: %v", err) + } + + if argHash.memory != 47104 { + t.Errorf("memory = %d, want 47104", argHash.memory) + } + if argHash.time != 1 { + t.Errorf("time = %d, want 1", argHash.time) + } + if argHash.threads != 1 { + t.Errorf("threads = %d, want 1", argHash.threads) + } +} + +func TestArgon2idCompare(t *testing.T) { + hasher := Argon2idHash{ + time: defaultArgon2idTime, + memory: defaultArgon2idMemory, + threads: defaultArgon2idThreads, + keyLen: defaultArgon2idKeylen, + } + + plaintext := []byte("test-password") + hashed, err := hasher.Hash(plaintext) + if err != nil { + t.Fatalf("Hash() error: %v", err) + } + + tests := []struct { + name string + plaintext []byte + wantMatch bool + }{ + {name: "correct password", plaintext: plaintext, wantMatch: true}, + {name: "wrong password", plaintext: []byte("wrong"), wantMatch: false}, + {name: "empty password", plaintext: []byte(""), wantMatch: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, err := hasher.Compare(hashed, tt.plaintext) + if err != nil { + t.Fatalf("Compare() error: %v", err) + } + if match != tt.wantMatch { + t.Errorf("Compare() = %v, want %v", match, tt.wantMatch) + } + }) + } +} + +func TestArgon2idHashRoundTrip(t *testing.T) { + hasher := Argon2idHash{ + time: defaultArgon2idTime, + memory: defaultArgon2idMemory, + threads: defaultArgon2idThreads, + keyLen: defaultArgon2idKeylen, + } + plaintext := []byte("round-trip-test") + + hashed, err := hasher.Hash(plaintext) + if err != nil { + t.Fatalf("Hash() error: %v", err) + } + + // Verify hash format starts with $argon2id$ + if !strings.HasPrefix(string(hashed), "$argon2id$v=") { + t.Errorf("Hash() output %q doesn't start with '$argon2id$v='", hashed) + } + + match, err := hasher.Compare(hashed, plaintext) + if err != nil { + t.Fatalf("Compare() error: %v", err) + } + if !match { + t.Error("Hash then Compare round-trip failed") + } +} + +func TestArgon2idHashProducesDifferentHashes(t *testing.T) { + hasher := Argon2idHash{ + time: defaultArgon2idTime, + memory: defaultArgon2idMemory, + threads: defaultArgon2idThreads, + keyLen: defaultArgon2idKeylen, + } + plaintext := []byte("same-password") + + hash1, err := hasher.Hash(plaintext) + if err != nil { + t.Fatalf("Hash() first error: %v", err) + } + hash2, err := hasher.Hash(plaintext) + if err != nil { + t.Fatalf("Hash() second error: %v", err) + } + + // Different salts should produce different hashes + if string(hash1) == string(hash2) { + t.Error("Hash() should produce different output on each call (different salt)") + } + + // But both should verify correctly + match1, _ := hasher.Compare(hash1, plaintext) + match2, _ := hasher.Compare(hash2, plaintext) + if !match1 || !match2 { + t.Error("Both hashes should verify against original plaintext") + } +} + +func TestArgon2idFakeHash(t *testing.T) { + hasher := Argon2idHash{} + fake := hasher.FakeHash() + + if len(fake) == 0 { + t.Fatal("FakeHash() returned empty result") + } + + // Should be a valid argon2id hash + _, _, err := DecodeHash(fake) + if err != nil { + t.Errorf("FakeHash() is not a valid argon2id hash: %v", err) + } + + // Should match the known plaintext "antitiming" + match, err := hasher.Compare(fake, []byte("antitiming")) + if err != nil { + t.Fatalf("Compare() with FakeHash error: %v", err) + } + if !match { + t.Error("FakeHash() should match plaintext 'antitiming'") + } +} + +func TestArgon2idCompareWithInvalidHash(t *testing.T) { + hasher := Argon2idHash{} + _, err := hasher.Compare([]byte("not-a-hash"), []byte("password")) + if err == nil { + t.Error("Compare() with invalid hash should return error") + } +} + +func TestArgon2idHashVersionInOutput(t *testing.T) { + hasher := Argon2idHash{ + time: defaultArgon2idTime, + memory: defaultArgon2idMemory, + threads: defaultArgon2idThreads, + keyLen: defaultArgon2idKeylen, + } + + hashed, err := hasher.Hash([]byte("test")) + if err != nil { + t.Fatalf("Hash() error: %v", err) + } + + // Verify version field matches current argon2 version + versionStr := "v=" + strings.Split(string(hashed), "$")[2] + if versionStr != "v=v=19" { + // argon2.Version is 0x13 = 19 + expectedVersion := "$argon2id$v=19$" + if !strings.Contains(string(hashed), expectedVersion[:len(expectedVersion)-1]) { + t.Errorf("Hash output should contain version %d, got %q", argon2.Version, hashed) + } + } +} + +func TestGenerateSalt(t *testing.T) { + salt1, err := generateSalt(16) + if err != nil { + t.Fatalf("generateSalt(16) error: %v", err) + } + if len(salt1) != 16 { + t.Errorf("generateSalt(16) length = %d, want 16", len(salt1)) + } + + salt2, err := generateSalt(16) + if err != nil { + t.Fatalf("generateSalt(16) second call error: %v", err) + } + + // Two salts should be different (with overwhelming probability) + if string(salt1) == string(salt2) { + t.Error("generateSalt() should produce different values on each call") + } +} + +func TestGenerateSaltLengths(t *testing.T) { + for _, length := range []int{8, 16, 32, 64} { + salt, err := generateSalt(length) + if err != nil { + t.Fatalf("generateSalt(%d) error: %v", length, err) + } + if len(salt) != length { + t.Errorf("generateSalt(%d) length = %d, want %d", length, len(salt), length) + } + } +} diff --git a/modules/caddyhttp/caddyauth/bcrypt_test.go b/modules/caddyhttp/caddyauth/bcrypt_test.go new file mode 100644 index 000000000..5c9684f2c --- /dev/null +++ b/modules/caddyhttp/caddyauth/bcrypt_test.go @@ -0,0 +1,155 @@ +package caddyauth + +import ( + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestBcryptHashCaddyModule(t *testing.T) { + b := BcryptHash{} + info := b.CaddyModule() + if info.ID != "http.authentication.hashes.bcrypt" { + t.Errorf("CaddyModule().ID = %v, want 'http.authentication.hashes.bcrypt'", info.ID) + } +} + +func TestBcryptHashCompare(t *testing.T) { + hasher := BcryptHash{} + plaintext := []byte("correct-password") + hashed, err := bcrypt.GenerateFromPassword(plaintext, bcrypt.MinCost) + if err != nil { + t.Fatalf("failed to generate bcrypt hash: %v", err) + } + + tests := []struct { + name string + hashed []byte + plaintext []byte + wantMatch bool + wantErr bool + }{ + { + name: "correct password matches", + hashed: hashed, + plaintext: plaintext, + wantMatch: true, + }, + { + name: "incorrect password does not match", + hashed: hashed, + plaintext: []byte("wrong-password"), + wantMatch: false, + }, + { + name: "empty password does not match", + hashed: hashed, + plaintext: []byte(""), + wantMatch: false, + }, + { + name: "invalid hash returns error", + hashed: []byte("not-a-bcrypt-hash"), + plaintext: plaintext, + wantMatch: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, err := hasher.Compare(tt.hashed, tt.plaintext) + if tt.wantErr { + if err == nil { + t.Error("Compare() should return error") + } + return + } + if err != nil { + t.Fatalf("Compare() error: %v", err) + } + if match != tt.wantMatch { + t.Errorf("Compare() = %v, want %v", match, tt.wantMatch) + } + }) + } +} + +func TestBcryptHashHash(t *testing.T) { + hasher := BcryptHash{} + plaintext := []byte("test-password") + hashed, err := hasher.Hash(plaintext) + if err != nil { + t.Fatalf("Hash() error: %v", err) + } + if len(hashed) == 0 { + t.Fatal("Hash() returned empty result") + } + + // Verify the hash is valid bcrypt + err = bcrypt.CompareHashAndPassword(hashed, plaintext) + if err != nil { + t.Errorf("Hash() produced invalid bcrypt hash: %v", err) + } + + // Verify re-hashing produces different output (different salt) + hashed2, err := hasher.Hash(plaintext) + if err != nil { + t.Fatalf("Hash() second call error: %v", err) + } + if string(hashed) == string(hashed2) { + t.Error("Hash() should produce different output on each call (different salt)") + } +} + +func TestBcryptHashRoundTrip(t *testing.T) { + hasher := BcryptHash{} + plaintext := []byte("round-trip-test") + hashed, err := hasher.Hash(plaintext) + if err != nil { + t.Fatalf("Hash() error: %v", err) + } + match, err := hasher.Compare(hashed, plaintext) + if err != nil { + t.Fatalf("Compare() error: %v", err) + } + if !match { + t.Error("Hash then Compare round-trip failed: password should match") + } +} + +func TestBcryptFakeHash(t *testing.T) { + hasher := BcryptHash{} + fake := hasher.FakeHash() + if len(fake) == 0 { + t.Fatal("FakeHash() returned empty result") + } + + // FakeHash should be a valid bcrypt hash matching "antitiming" + err := bcrypt.CompareHashAndPassword(fake, []byte("antitiming")) + if err != nil { + t.Errorf("FakeHash() is not a valid bcrypt hash of 'antitiming': %v", err) + } + + // Calling FakeHash multiple times should return the same value + fake2 := hasher.FakeHash() + if string(fake) != string(fake2) { + t.Error("FakeHash() should return constant value") + } +} + +func TestBcryptHashWithCustomCost(t *testing.T) { + hasher := BcryptHash{cost: bcrypt.MinCost} + plaintext := []byte("low-cost-test") + hashed, err := hasher.Hash(plaintext) + if err != nil { + t.Fatalf("Hash() with MinCost error: %v", err) + } + match, err := hasher.Compare(hashed, plaintext) + if err != nil { + t.Fatalf("Compare() error: %v", err) + } + if !match { + t.Error("Hash/Compare with MinCost should match") + } +}