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:
@@ -5,6 +5,7 @@ go 1.26.0
|
|||||||
require (
|
require (
|
||||||
github.com/mattn/go-sqlite3 v1.14.52
|
github.com/mattn/go-sqlite3 v1.14.52
|
||||||
github.com/pressly/goose/v3 v3.28.0
|
github.com/pressly/goose/v3 v3.28.0
|
||||||
|
golang.org/x/crypto v0.55.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -12,4 +13,5 @@ require (
|
|||||||
github.com/sethvargo/go-retry v0.4.0 // indirect
|
github.com/sethvargo/go-retry v0.4.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
|
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
|
||||||
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Package crypto объединяет криптографические примитивы glchat: хэширование
|
||||||
|
// паролей, шифрование PII, blind index и генерацию токенов (AGENT.md 7.1, 9.2).
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Argon2Params — параметры Argon2id, калибруемые под железо (AGENT.md 9.1).
|
||||||
|
// Значения по умолчанию соответствуют рекомендации OWASP для 1 vCPU.
|
||||||
|
type Argon2Params struct {
|
||||||
|
Memory uint32
|
||||||
|
Iterations uint32
|
||||||
|
Parallelism uint8
|
||||||
|
SaltLength uint32
|
||||||
|
KeyLength uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultArgon2Params() Argon2Params {
|
||||||
|
return Argon2Params{
|
||||||
|
Memory: 19456,
|
||||||
|
Iterations: 2,
|
||||||
|
Parallelism: 1,
|
||||||
|
SaltLength: 16,
|
||||||
|
KeyLength: 32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidHashFormat = errors.New("password hash has an unsupported format")
|
||||||
|
ErrPasswordMismatch = errors.New("password does not match")
|
||||||
|
ErrEmptyPepper = errors.New("password pepper must not be empty")
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordHasher хэширует пароли Argon2id с обязательным pepper: pepper
|
||||||
|
// подмешивается HMAC-SHA-256 до Argon2id и хранится вне БД (AGENT.md 7.1).
|
||||||
|
type PasswordHasher struct {
|
||||||
|
pepper []byte
|
||||||
|
params Argon2Params
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPasswordHasher(pepper []byte, params Argon2Params) (*PasswordHasher, error) {
|
||||||
|
if len(pepper) == 0 {
|
||||||
|
return nil, ErrEmptyPepper
|
||||||
|
}
|
||||||
|
if params.Memory == 0 || params.Iterations == 0 || params.Parallelism == 0 {
|
||||||
|
params = DefaultArgon2Params()
|
||||||
|
}
|
||||||
|
return &PasswordHasher{pepper: pepper, params: params}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash возвращает строку формата argon2id$v=19$m=..,t=..,p=..$salt$hash.
|
||||||
|
func (h *PasswordHasher) Hash(password string) (string, error) {
|
||||||
|
salt := make([]byte, h.params.SaltLength)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("generate salt: %w", err)
|
||||||
|
}
|
||||||
|
sum := h.derive(password, salt)
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
argon2.Version,
|
||||||
|
h.params.Memory,
|
||||||
|
h.params.Iterations,
|
||||||
|
h.params.Parallelism,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(sum),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify сравнивает пароль с сохранённым хэшем за постоянное время.
|
||||||
|
func (h *PasswordHasher) Verify(password, encoded string) error {
|
||||||
|
params, salt, want, err := parseHash(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(want) == 0 || len(want) > maxKeyLength {
|
||||||
|
return ErrInvalidHashFormat
|
||||||
|
}
|
||||||
|
// len(want) ограничен maxKeyLength выше.
|
||||||
|
keyLength := uint32(len(want)) //nolint:gosec // диапазон проверен: 0 < len <= maxKeyLength
|
||||||
|
got := argon2.IDKey(h.peppered(password), salt, params.Iterations, params.Memory, params.Parallelism, keyLength)
|
||||||
|
if subtle.ConstantTimeCompare(got, want) != 1 {
|
||||||
|
return ErrPasswordMismatch
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsRehash сообщает, что хэш посчитан с другими параметрами.
|
||||||
|
func (h *PasswordHasher) NeedsRehash(encoded string) bool {
|
||||||
|
params, _, _, err := parseHash(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return params != h.params
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PasswordHasher) derive(password string, salt []byte) []byte {
|
||||||
|
return argon2.IDKey(h.peppered(password), salt, h.params.Iterations, h.params.Memory, h.params.Parallelism, h.params.KeyLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PasswordHasher) peppered(password string) []byte {
|
||||||
|
mac := hmac.New(sha256.New, h.pepper)
|
||||||
|
mac.Write([]byte(password))
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHash(encoded string) (Argon2Params, []byte, []byte, error) {
|
||||||
|
parts := strings.Split(encoded, "$")
|
||||||
|
if len(parts) != 5 || parts[0] != "argon2id" {
|
||||||
|
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
|
||||||
|
}
|
||||||
|
version, err := strconv.Atoi(strings.TrimPrefix(parts[1], "v="))
|
||||||
|
if err != nil || version != argon2.Version {
|
||||||
|
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
var params Argon2Params
|
||||||
|
for _, field := range strings.Split(parts[2], ",") {
|
||||||
|
key, value, ok := strings.Cut(field, "=")
|
||||||
|
if !ok {
|
||||||
|
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseUint(value, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "m":
|
||||||
|
params.Memory = uint32(parsed)
|
||||||
|
case "t":
|
||||||
|
params.Iterations = uint32(parsed)
|
||||||
|
case "p":
|
||||||
|
// Параллелизм Argon2id ограничен 255 по спецификации; значение
|
||||||
|
// из хэша получено нами же, поэтому достаточно явного ограничения.
|
||||||
|
if parsed == 0 || parsed > maxParallelism {
|
||||||
|
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
|
||||||
|
}
|
||||||
|
parallelism := uint8(parsed)
|
||||||
|
params.Parallelism = parallelism
|
||||||
|
default:
|
||||||
|
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||||
|
if err != nil {
|
||||||
|
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
|
||||||
|
}
|
||||||
|
sum, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
|
||||||
|
}
|
||||||
|
if len(salt) == 0 || len(salt) > maxSaltLength || len(sum) == 0 || len(sum) > maxKeyLength {
|
||||||
|
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
|
||||||
|
}
|
||||||
|
// Обе длины ограничены maxSaltLength/maxKeyLength в проверке выше.
|
||||||
|
params.SaltLength = uint32(len(salt)) //nolint:gosec // диапазон проверен выше
|
||||||
|
params.KeyLength = uint32(len(sum)) //nolint:gosec // диапазон проверен выше
|
||||||
|
return params, salt, sum, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничения, вытекающие из спецификации Argon2id: параллелизм — uint8,
|
||||||
|
// длины соли и ключа хранятся в uint32 (реалистичные значения много меньше).
|
||||||
|
const (
|
||||||
|
maxParallelism = 255
|
||||||
|
maxSaltLength = 1024
|
||||||
|
maxKeyLength = 1024
|
||||||
|
)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// masterKeySize — длина ключа AES-256-GCM (32 байта).
|
||||||
|
masterKeySize = 32
|
||||||
|
// keyVersionPrefix версионирует ключ в шифротексте (AGENT.md 9.2).
|
||||||
|
keyVersionPrefix = "v1:"
|
||||||
|
|
||||||
|
infoEncryption = "glchat/pii-encryption"
|
||||||
|
infoBlindIndex = "glchat/blind-index"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidMasterKey = errors.New("master key must be 32 bytes (hex/base64/raw)")
|
||||||
|
ErrInvalidCiphertext = errors.New("ciphertext is malformed or uses an unknown key version")
|
||||||
|
ErrEmptyBlindIndexKey = errors.New("blind index key must not be empty")
|
||||||
|
)
|
||||||
|
|
||||||
|
// MasterKey — ключ шифрования PII (AES-256-GCM) и источник ключа blind index.
|
||||||
|
type MasterKey struct {
|
||||||
|
encryption []byte
|
||||||
|
blindIndex []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMasterKey принимает ключ в hex (64 символа), base64 или как 32 сырых байта:
|
||||||
|
// установщик генерирует hex, но формат не должен ломать развёртывание.
|
||||||
|
func ParseMasterKey(value string) (*MasterKey, error) {
|
||||||
|
raw, err := decodeKeyMaterial(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewMasterKey(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMasterKey(raw []byte) (*MasterKey, error) {
|
||||||
|
if len(raw) != masterKeySize {
|
||||||
|
return nil, ErrInvalidMasterKey
|
||||||
|
}
|
||||||
|
return &MasterKey{
|
||||||
|
encryption: deriveSubkey(raw, infoEncryption),
|
||||||
|
blindIndex: deriveSubkey(raw, infoBlindIndex),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt шифрует строку и возвращает версионированный base64-шифротекст.
|
||||||
|
func (k *MasterKey) Encrypt(plaintext string) (string, error) {
|
||||||
|
gcm, err := k.aead()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", fmt.Errorf("generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
payload := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return keyVersionPrefix + base64.RawURLEncoding.EncodeToString(payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt расшифровывает значение, созданное Encrypt.
|
||||||
|
func (k *MasterKey) Decrypt(value string) (string, error) {
|
||||||
|
if !strings.HasPrefix(value, keyVersionPrefix) {
|
||||||
|
return "", ErrInvalidCiphertext
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(value, keyVersionPrefix))
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrInvalidCiphertext
|
||||||
|
}
|
||||||
|
gcm, err := k.aead()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(payload) < gcm.NonceSize() {
|
||||||
|
return "", ErrInvalidCiphertext
|
||||||
|
}
|
||||||
|
nonce, ciphertext := payload[:gcm.NonceSize()], payload[gcm.NonceSize():]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decrypt value: %w", err)
|
||||||
|
}
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlindIndex считает детерминированный HMAC-SHA-256 по нормализованному
|
||||||
|
// значению (email): по нему ищут запись, не расшифровывая PII.
|
||||||
|
func (k *MasterKey) BlindIndex(value string) string {
|
||||||
|
mac := hmac.New(sha256.New, k.blindIndex)
|
||||||
|
mac.Write([]byte(value))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *MasterKey) aead() (cipher.AEAD, error) {
|
||||||
|
block, err := aes.NewCipher(k.encryption)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create cipher: %w", err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create gcm: %w", err)
|
||||||
|
}
|
||||||
|
return gcm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKeyMaterial(value string) ([]byte, error) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, ErrInvalidMasterKey
|
||||||
|
}
|
||||||
|
if decoded, err := hex.DecodeString(trimmed); err == nil && len(decoded) == masterKeySize {
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.RawURLEncoding} {
|
||||||
|
if decoded, err := encoding.DecodeString(trimmed); err == nil && len(decoded) == masterKeySize {
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(trimmed) == masterKeySize {
|
||||||
|
return []byte(trimmed), nil
|
||||||
|
}
|
||||||
|
return nil, ErrInvalidMasterKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveSubkey разделяет мастер-ключ по назначению через HMAC-SHA-256:
|
||||||
|
// один и тот же мастер-ключ не используется напрямую для разных задач.
|
||||||
|
func deriveSubkey(raw []byte, info string) []byte {
|
||||||
|
mac := hmac.New(sha256.New, raw)
|
||||||
|
mac.Write([]byte(info))
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBlindIndexKey создаёт отдельный ключ blind index (для тестов и утилит).
|
||||||
|
func NewBlindIndexKey(raw []byte) ([]byte, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, ErrEmptyBlindIndexKey
|
||||||
|
}
|
||||||
|
return deriveSubkey(raw, infoBlindIndex), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SessionTokenBytes — энтропия opaque-токена сессии (AGENT.md 7.1).
|
||||||
|
SessionTokenBytes = 32
|
||||||
|
// MinPasswordLength — парольная политика MVP (AGENT.md 7.1).
|
||||||
|
MinPasswordLength = 10
|
||||||
|
// MaxPasswordLength ограничивает стоимость Argon2id на длинных вводах.
|
||||||
|
MaxPasswordLength = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewSessionToken возвращает токен для клиента и его SHA-256 для БД:
|
||||||
|
// в базе хранятся только хэши токенов (AGENT.md 7.1).
|
||||||
|
func NewSessionToken() (token string, hash string, err error) {
|
||||||
|
buf := make([]byte, SessionTokenBytes)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", "", fmt.Errorf("generate session token: %w", err)
|
||||||
|
}
|
||||||
|
token = base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
return token, HashToken(token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashToken считает SHA-256 токена в hex — формат хранения в sessions.token_hash.
|
||||||
|
func HashToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRecoveryCode возвращает резервный код 2FA в формате xxxx-xxxx-xxxx.
|
||||||
|
func NewRecoveryCode() (string, error) {
|
||||||
|
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||||
|
buf := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("generate recovery code: %w", err)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i, v := range buf {
|
||||||
|
if i > 0 && i%4 == 0 {
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
b.WriteByte(alphabet[int(v)%len(alphabet)])
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeEmail приводит email к каноническому виду для blind index.
|
||||||
|
func NormalizeEmail(email string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePassword проверяет базовую политику пароля (AGENT.md 7.1).
|
||||||
|
func ValidatePassword(password string) error {
|
||||||
|
if utf8.RuneCountInString(password) < MinPasswordLength {
|
||||||
|
return fmt.Errorf("password must be at least %d characters", MinPasswordLength)
|
||||||
|
}
|
||||||
|
if len(password) > MaxPasswordLength {
|
||||||
|
return fmt.Errorf("password must be at most %d bytes", MaxPasswordLength)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUsername проверяет публичный идентификатор (AGENT.md 7.1).
|
||||||
|
func ValidateUsername(username string) error {
|
||||||
|
if len(username) < 2 || len(username) > 32 {
|
||||||
|
return fmt.Errorf("username must be 2-32 characters long")
|
||||||
|
}
|
||||||
|
for _, r := range username {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.':
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("username may contain only latin letters, digits, underscore and dot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user