package crypto import ( "encoding/hex" "errors" "strings" "testing" ) func testHasher(t *testing.T) *PasswordHasher { t.Helper() // Параметры снижены: тесты не должны тратить секунды на каждый хэш. hasher, err := NewPasswordHasher([]byte("unit-test-pepper"), Argon2Params{ Memory: 1024, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32, }) if err != nil { t.Fatalf("NewPasswordHasher: %v", err) } return hasher } func TestPasswordHashAndVerify(t *testing.T) { hasher := testHasher(t) hash, err := hasher.Hash("correct horse battery staple") if err != nil { t.Fatalf("Hash: %v", err) } if !strings.HasPrefix(hash, "argon2id$") { t.Fatalf("unexpected hash format: %s", hash) } if err := hasher.Verify("correct horse battery staple", hash); err != nil { t.Fatalf("Verify: %v", err) } if err := hasher.Verify("wrong password", hash); !errors.Is(err, ErrPasswordMismatch) { t.Fatalf("Verify with wrong password = %v, want ErrPasswordMismatch", err) } } func TestPasswordHashIsSalted(t *testing.T) { hasher := testHasher(t) first, err := hasher.Hash("same-password-value") if err != nil { t.Fatalf("Hash: %v", err) } second, err := hasher.Hash("same-password-value") if err != nil { t.Fatalf("Hash: %v", err) } if first == second { t.Fatal("hashes of the same password must differ (unique salt)") } } func TestPepperIsRequired(t *testing.T) { if _, err := NewPasswordHasher(nil, Argon2Params{}); !errors.Is(err, ErrEmptyPepper) { t.Fatalf("NewPasswordHasher without pepper = %v, want ErrEmptyPepper", err) } } func TestPepperChangesHash(t *testing.T) { params := Argon2Params{Memory: 1024, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32} first, err := NewPasswordHasher([]byte("pepper-one"), params) if err != nil { t.Fatalf("NewPasswordHasher: %v", err) } second, err := NewPasswordHasher([]byte("pepper-two"), params) if err != nil { t.Fatalf("NewPasswordHasher: %v", err) } hash, err := first.Hash("password-value-1") if err != nil { t.Fatalf("Hash: %v", err) } if err := second.Verify("password-value-1", hash); !errors.Is(err, ErrPasswordMismatch) { t.Fatal("hash must not verify with a different pepper") } } func TestNeedsRehash(t *testing.T) { weak := Argon2Params{Memory: 1024, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32} hasher, err := NewPasswordHasher([]byte("pepper"), weak) if err != nil { t.Fatalf("NewPasswordHasher: %v", err) } hash, err := hasher.Hash("password-value-1") if err != nil { t.Fatalf("Hash: %v", err) } if hasher.NeedsRehash(hash) { t.Fatal("hash should match the hasher parameters") } strong := weak strong.Memory = 2048 upgraded, err := NewPasswordHasher([]byte("pepper"), strong) if err != nil { t.Fatalf("NewPasswordHasher: %v", err) } if !upgraded.NeedsRehash(hash) { t.Fatal("hash with weaker parameters must need a rehash") } if !hasher.NeedsRehash("garbage") { t.Fatal("unparsable hash must need a rehash") } } func TestMasterKeyEncryptDecrypt(t *testing.T) { key, err := ParseMasterKey(strings.Repeat("ab", 32)) if err != nil { t.Fatalf("ParseMasterKey: %v", err) } sealed, err := key.Encrypt("user@example.com") if err != nil { t.Fatalf("Encrypt: %v", err) } if !strings.HasPrefix(sealed, "v1:") { t.Fatalf("ciphertext must be versioned, got %s", sealed) } opened, err := key.Decrypt(sealed) if err != nil { t.Fatalf("Decrypt: %v", err) } if opened != "user@example.com" { t.Fatalf("Decrypt = %q", opened) } } func TestMasterKeyRejectsForeignCiphertext(t *testing.T) { first, _ := ParseMasterKey(strings.Repeat("ab", 32)) second, _ := ParseMasterKey(strings.Repeat("cd", 32)) sealed, err := first.Encrypt("secret-value") if err != nil { t.Fatalf("Encrypt: %v", err) } if _, err := second.Decrypt(sealed); err == nil { t.Fatal("another key must not decrypt the value") } if _, err := first.Decrypt("plain-value"); !errors.Is(err, ErrInvalidCiphertext) { t.Fatalf("Decrypt(plain) = %v, want ErrInvalidCiphertext", err) } if _, err := first.Decrypt("v2:AAAA"); !errors.Is(err, ErrInvalidCiphertext) { t.Fatal("unknown key version must be rejected") } } func TestMasterKeyAcceptsHexBase64AndRaw(t *testing.T) { raw := make([]byte, 32) for i := range raw { raw[i] = byte(i) } hexForm := hex.EncodeToString(raw) for name, value := range map[string]string{ "hex": hexForm, "raw": string(raw), } { key, err := ParseMasterKey(value) if err != nil { t.Fatalf("%s: ParseMasterKey: %v", name, err) } sealed, err := key.Encrypt("value") if err != nil { t.Fatalf("%s: Encrypt: %v", name, err) } if _, err := key.Decrypt(sealed); err != nil { t.Fatalf("%s: Decrypt: %v", name, err) } } if _, err := ParseMasterKey("too-short"); !errors.Is(err, ErrInvalidMasterKey) { t.Fatalf("ParseMasterKey(short) = %v, want ErrInvalidMasterKey", err) } } func TestBlindIndexIsStableAndUnique(t *testing.T) { key, _ := ParseMasterKey(strings.Repeat("11", 32)) other, _ := ParseMasterKey(strings.Repeat("22", 32)) first := key.BlindIndex(NormalizeEmail(" User@Example.COM ")) same := key.BlindIndex(NormalizeEmail("user@example.com")) another := key.BlindIndex(NormalizeEmail("second@example.com")) if first != same { t.Fatal("blind index must be stable for the same normalized value") } if first == another { t.Fatal("blind index must differ for different values") } if first == other.BlindIndex(NormalizeEmail("user@example.com")) { t.Fatal("blind index must depend on the key") } if len(first) != 64 { t.Fatalf("blind index length = %d, want 64 hex chars", len(first)) } } func TestSessionTokenHashing(t *testing.T) { token, hash, err := NewSessionToken() if err != nil { t.Fatalf("NewSessionToken: %v", err) } if token == "" || hash == "" { t.Fatal("token and hash must not be empty") } if token == hash { t.Fatal("token must not be stored as is") } if HashToken(token) != hash { t.Fatal("HashToken must reproduce the stored hash") } other, _, err := NewSessionToken() if err != nil { t.Fatalf("NewSessionToken: %v", err) } if other == token { t.Fatal("tokens must be unique") } } func TestRecoveryCodeFormat(t *testing.T) { code, err := NewRecoveryCode() if err != nil { t.Fatalf("NewRecoveryCode: %v", err) } if len(code) != 14 || strings.Count(code, "-") != 2 { t.Fatalf("recovery code %q has unexpected format", code) } } func TestValidatePasswordAndUsername(t *testing.T) { if err := ValidatePassword("short"); err == nil { t.Fatal("short password must be rejected") } if err := ValidatePassword("long-enough-password"); err != nil { t.Fatalf("valid password rejected: %v", err) } for _, username := range []string{"ab", "user.name", "User_123"} { if err := ValidateUsername(username); err != nil { t.Fatalf("valid username %q rejected: %v", username, err) } } for _, username := range []string{"a", "with space", "кириллица", strings.Repeat("x", 33)} { if err := ValidateUsername(username); err == nil { t.Fatalf("invalid username %q accepted", username) } } }