feat(crypto): крипто-слой Фазы 1 — Argon2id+pepper, AES-256-GCM, blind index
- internal/crypto/password.go: Argon2id с параметрами под 1 vCPU (OWASP), обязательный pepper (HMAC-SHA-256 до Argon2id), разбор формата хэша, NeedsRehash для калибровки параметров - internal/crypto/secretbox.go: AES-256-GCM с версионированным ключом (v1:), разделение мастер-ключа на подключи шифрования и blind index (HMAC-SHA-256), blind index для поиска по email без расшифровки (§9.2) - internal/crypto/tokens.go: opaque-токены сессий (хранится только SHA-256), резервные коды 2FA, нормализация email, политика пароля и username - тесты: соль, pepper, версии ключа, чужие шифротексты, стабильность blind index - go.mod: добавлены chi v5, huma v2, pquerna/otp (подключаются следующими шагами)
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user