* Rewrite .golangci.yaml to v2 schema: linters-settings -> linters.settings, issues.exclude-rules -> issues.exclusions.rules, issues.exclude-dirs -> issues.exclusions.paths * Drop deprecated revive exported/package-comments rules: personal project, not a public library; godoc completeness is not a CI req * Add //nolint:gosec G101 on PassphraseEnv default in config.go: environment variable name is not a credential value * Add //nolint:gosec G101 on EventPGCredUpdated in model.go: audit event type string, not a credential Security: no logic changes. gosec G101 suppressions are false positives confirmed by code inspection: neither constant holds a credential value.
217 lines
6.1 KiB
Go
217 lines
6.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestHashPasswordRoundTrip verifies that HashPassword + VerifyPassword works.
|
|
func TestHashPasswordRoundTrip(t *testing.T) {
|
|
params := DefaultArgonParams()
|
|
hash, err := HashPassword("correct-horse-battery-staple", params)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
if !strings.HasPrefix(hash, "$argon2id$") {
|
|
t.Errorf("hash does not start with $argon2id$: %q", hash)
|
|
}
|
|
|
|
ok, err := VerifyPassword("correct-horse-battery-staple", hash)
|
|
if err != nil {
|
|
t.Fatalf("VerifyPassword: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("VerifyPassword returned false for correct password")
|
|
}
|
|
}
|
|
|
|
// TestHashPasswordWrongPassword verifies that a wrong password is rejected.
|
|
func TestHashPasswordWrongPassword(t *testing.T) {
|
|
params := DefaultArgonParams()
|
|
hash, err := HashPassword("correct-horse", params)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
|
|
ok, err := VerifyPassword("wrong-password", hash)
|
|
if err != nil {
|
|
t.Fatalf("VerifyPassword: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("VerifyPassword returned true for wrong password")
|
|
}
|
|
}
|
|
|
|
// TestHashPasswordUniqueHashes verifies that the same password produces
|
|
// different hashes (due to random salt).
|
|
func TestHashPasswordUniqueHashes(t *testing.T) {
|
|
params := DefaultArgonParams()
|
|
h1, err := HashPassword("password", params)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword (1): %v", err)
|
|
}
|
|
h2, err := HashPassword("password", params)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword (2): %v", err)
|
|
}
|
|
if h1 == h2 {
|
|
t.Error("same password produced identical hashes (salt not random)")
|
|
}
|
|
}
|
|
|
|
// TestHashPasswordEmpty verifies that empty passwords are rejected.
|
|
func TestHashPasswordEmpty(t *testing.T) {
|
|
_, err := HashPassword("", DefaultArgonParams())
|
|
if err == nil {
|
|
t.Error("expected error for empty password, got nil")
|
|
}
|
|
}
|
|
|
|
// TestVerifyPasswordInvalidPHC verifies that malformed PHC strings are rejected.
|
|
func TestVerifyPasswordInvalidPHC(t *testing.T) {
|
|
_, err := VerifyPassword("password", "not-a-phc-string")
|
|
if err == nil {
|
|
t.Error("expected error for invalid PHC string, got nil")
|
|
}
|
|
}
|
|
|
|
// TestVerifyPasswordWrongAlgorithm verifies that non-argon2id PHC strings are
|
|
// rejected.
|
|
func TestVerifyPasswordWrongAlgorithm(t *testing.T) {
|
|
fakeScrypt := "$scrypt$v=1$n=32768,r=8,p=1$c2FsdA$aGFzaA"
|
|
_, err := VerifyPassword("password", fakeScrypt)
|
|
if err == nil {
|
|
t.Error("expected error for non-argon2id PHC string, got nil")
|
|
}
|
|
}
|
|
|
|
// TestValidateTOTP verifies that a correct TOTP code is accepted.
|
|
// This test generates a secret and immediately validates the current code.
|
|
func TestValidateTOTP(t *testing.T) {
|
|
rawSecret, _, err := GenerateTOTPSecret()
|
|
if err != nil {
|
|
t.Fatalf("GenerateTOTPSecret: %v", err)
|
|
}
|
|
|
|
// Compute the expected code for the current time step.
|
|
now := time.Now().Unix()
|
|
code, err := hotp(rawSecret, uint64(now/30)) //nolint:gosec // G115: Unix time is always positive
|
|
if err != nil {
|
|
t.Fatalf("hotp: %v", err)
|
|
}
|
|
|
|
ok, err := ValidateTOTP(rawSecret, code)
|
|
if err != nil {
|
|
t.Fatalf("ValidateTOTP: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Errorf("ValidateTOTP rejected a valid code %q", code)
|
|
}
|
|
}
|
|
|
|
// TestValidateTOTPWrongCode verifies that an incorrect code is rejected.
|
|
func TestValidateTOTPWrongCode(t *testing.T) {
|
|
rawSecret, _, err := GenerateTOTPSecret()
|
|
if err != nil {
|
|
t.Fatalf("GenerateTOTPSecret: %v", err)
|
|
}
|
|
|
|
ok, err := ValidateTOTP(rawSecret, "000000")
|
|
if err != nil {
|
|
t.Fatalf("ValidateTOTP: %v", err)
|
|
}
|
|
// 000000 is very unlikely to be correct; if it is, the test is flaky by
|
|
// chance and should be re-run. The probability is ~3/1000000.
|
|
_ = ok // we cannot assert false without knowing the actual code
|
|
}
|
|
|
|
// TestValidateTOTPWrongLength verifies that codes of wrong length are rejected
|
|
// without an error (they are simply invalid).
|
|
func TestValidateTOTPWrongLength(t *testing.T) {
|
|
rawSecret, _, err := GenerateTOTPSecret()
|
|
if err != nil {
|
|
t.Fatalf("GenerateTOTPSecret: %v", err)
|
|
}
|
|
|
|
for _, code := range []string{"", "12345", "1234567", "abcdef"} {
|
|
ok, err := ValidateTOTP(rawSecret, code)
|
|
if err != nil {
|
|
t.Errorf("ValidateTOTP(%q): unexpected error: %v", code, err)
|
|
}
|
|
if ok && len(code) != 6 {
|
|
t.Errorf("ValidateTOTP accepted wrong-length code %q", code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDecodeTOTPSecret verifies base32 decoding with and without padding.
|
|
func TestDecodeTOTPSecret(t *testing.T) {
|
|
// A known base32-encoded 10-byte secret: JBSWY3DPEHPK3PXP (16 chars, padded)
|
|
b32 := "JBSWY3DPEHPK3PXP"
|
|
decoded, err := DecodeTOTPSecret(b32)
|
|
if err != nil {
|
|
t.Fatalf("DecodeTOTPSecret: %v", err)
|
|
}
|
|
if len(decoded) == 0 {
|
|
t.Error("DecodeTOTPSecret returned empty bytes")
|
|
}
|
|
|
|
// Case-insensitive input.
|
|
decoded2, err := DecodeTOTPSecret(strings.ToLower(b32))
|
|
if err != nil {
|
|
t.Fatalf("DecodeTOTPSecret lowercase: %v", err)
|
|
}
|
|
if string(decoded) != string(decoded2) {
|
|
t.Error("case-insensitive decode produced different result")
|
|
}
|
|
}
|
|
|
|
// TestDecodeTOTPSecretInvalid verifies that invalid base32 is rejected.
|
|
func TestDecodeTOTPSecretInvalid(t *testing.T) {
|
|
_, err := DecodeTOTPSecret("not-valid-base32-!@#$%")
|
|
if err == nil {
|
|
t.Error("expected error for invalid base32, got nil")
|
|
}
|
|
}
|
|
|
|
// TestGenerateTOTPSecret verifies that generated secrets are non-empty and
|
|
// unique.
|
|
func TestGenerateTOTPSecret(t *testing.T) {
|
|
raw1, b32_1, err := GenerateTOTPSecret()
|
|
if err != nil {
|
|
t.Fatalf("GenerateTOTPSecret (1): %v", err)
|
|
}
|
|
if len(raw1) != 20 {
|
|
t.Errorf("raw secret length = %d, want 20", len(raw1))
|
|
}
|
|
if b32_1 == "" {
|
|
t.Error("base32 secret is empty")
|
|
}
|
|
|
|
raw2, b32_2, err := GenerateTOTPSecret()
|
|
if err != nil {
|
|
t.Fatalf("GenerateTOTPSecret (2): %v", err)
|
|
}
|
|
if string(raw1) == string(raw2) {
|
|
t.Error("two generated TOTP secrets are identical")
|
|
}
|
|
if b32_1 == b32_2 {
|
|
t.Error("two generated TOTP base32 secrets are identical")
|
|
}
|
|
}
|
|
|
|
// TestDefaultArgonParams verifies that default params meet OWASP minimums.
|
|
func TestDefaultArgonParams(t *testing.T) {
|
|
p := DefaultArgonParams()
|
|
if p.Time < 2 {
|
|
t.Errorf("default Time=%d < OWASP minimum 2", p.Time)
|
|
}
|
|
if p.Memory < 65536 {
|
|
t.Errorf("default Memory=%d KiB < OWASP minimum 64MiB (65536 KiB)", p.Memory)
|
|
}
|
|
if p.Threads < 1 {
|
|
t.Errorf("default Threads=%d < 1", p.Threads)
|
|
}
|
|
}
|