245 lines
7.0 KiB
Go
245 lines
7.0 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|