Опирается на правила:
R-SEC-CRYPTO-1…R-SEC-CRYPTO-5иR-SEC-CRYPTO-X1из Security Style Guide → раздел 5. Криптография в коде.
Важно знать
- Пароли:
golang.org/x/crypto/bcryptс cost ≥ 12. Никогдаcrypto/md5,crypto/sha1,crypto/sha256без KDF.- Random: только
crypto/rand.math/rand— только не-security код (shuffle, jitter).gosecG404 ловитmath/randв security-контексте автоматически.- Симметричное шифрование: AES-GCM с рандомным nonce 12 байт (
io.ReadFull(rand.Reader, nonce)), новый на каждый encrypt.- TLS: минимум 1.2 (
tls.VersionTLS12). Go 1.21+ по умолчанию. Конфигурируется на reverse-proxy или явно при TLS-терминировании в приложении.- JWT verification: только
github.com/golang-jwt/jwt/v5с проверкой подписи. Ручной парсинг без верификации — критическая ошибка.- Hardcoded ключи/nonce — запрещены. Ключи — только через
envconfigиз Vault/KMS; nonce генерируетсяcrypto/randна каждое шифрование.- gosec G401 (
crypto/sha1), G501 (crypto/md5), G404 (math/rand) — эти коды фиксирует PR-gate и блокирует сборку.
Не пиши свою криптографию. Правило означает не «не понимай алгоритм», а «не реализуй сам, не выбирай экзотические режимы, не делай custom-mixing». Go-стандартная библиотека (crypto/aes, crypto/cipher, crypto/rand) и golang.org/x/crypto/bcrypt закрывают 95% случаев без единой строки экзотики.
Пароли — bcrypt
R-SEC-CRYPTO-1: один хешер для паролей.
// core/customer/password.go
package customer
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
const bcryptCost = 12
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(b), nil
}
func CheckPassword(hash, plain string) error {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)); err != nil {
return &InvalidCredentialsError{}
}
return nil
}
Домен Customer использует эти функции в регистрации и логине:
// core/customer/register_handler.go
package customer
import (
"context"
"fmt"
)
type RegisterCustomerCommand struct {
Email string
Password string
}
type RegisterCustomerHandler struct {
repo CustomerRepository
}
func (h *RegisterCustomerHandler) Handle(ctx context.Context, cmd RegisterCustomerCommand) (*Customer, error) {
hashed, err := HashPassword(cmd.Password)
if err != nil {
return nil, fmt.Errorf("register customer: %w", err)
}
c := NewCustomer(cmd.Email, hashed)
if err := h.repo.Save(ctx, c); err != nil {
return nil, fmt.Errorf("register customer: %w", err)
}
return c, nil
}
// core/customer/login_handler.go
package customer
import (
"context"
"fmt"
)
type LoginCommand struct {
Email string
Password string
}
type LoginHandler struct {
repo CustomerRepository
}
func (h *LoginHandler) Handle(ctx context.Context, cmd LoginCommand) (*Customer, error) {
c, err := h.repo.FindByEmail(ctx, cmd.Email)
if err != nil {
return nil, &InvalidCredentialsError{}
}
if err := CheckPassword(c.PasswordHash, cmd.Password); err != nil {
return nil, err
}
return c, nil
}
Свойства bcrypt:
- Adaptive — cost 12 = ~250ms на хеш в 2026, экспоненциально растёт с cost.
- Built-in salt — соль генерируется автоматически и хранится внутри строки (
$2a$12$...). - Совместимость версий —
CompareHashAndPasswordработает с любым cost, можно повышать постепенно.
Для специальных случаев — golang.org/x/crypto/argon2 (победитель Password Hashing Competition 2015, устойчивее к GPU-атакам). Дефолт в UCP — bcrypt cost 12.
// КАТАСТРОФА
import "crypto/md5"
h := md5.Sum([]byte(password))
import "crypto/sha256"
h := sha256.Sum256([]byte(password))
MD5/SHA без salt — взламываются rainbow-table за секунды. gosec G401 и G501 ловят на PR-gate.
crypto/rand для security
R-SEC-CRYPTO-2: только crypto/rand.
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
func GenerateToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
return base64.URLEncoding.EncodeToString(b), nil
}
math/rand — детерминированный: seed по умолчанию предсказуем, атакующий может воспроизвести последовательность. Использование math/rand для токенов (сессия, CSRF, сброс пароля), nonce или API-ключей — критическая уязвимость. gosec G404 блокирует это на PR.
math/rand допустим только для:
- Тестов (детерминированность выгодна).
- Jitter в retry-политиках (
R-RES-RE-3). - Shuffle не-security коллекций (например, перемешать порядок отображения товаров в Product-каталоге).
// КАТАСТРОФА — gosec G404
import "math/rand"
token := rand.Int63()
// ХОРОШО
import "crypto/rand"
b := make([]byte, 32)
rand.Read(b)
AES-GCM для симметричного шифрования
R-SEC-CRYPTO-3: один правильный режим.
// core/order/encryption.go
package order
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"io"
)
func EncryptPaymentData(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("aes-gcm: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("generate nonce: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func DecryptPaymentData(key, ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("aes-gcm: %w", err)
}
if len(ciphertext) < gcm.NonceSize() {
return nil, fmt.Errorf("decrypt payment data: ciphertext too short")
}
nonce, ct := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("decrypt payment data: %w", err)
}
return plain, nil
}
gcm.Seal(nonce, nonce, plaintext, nil) — nonce prepend-ится к ciphertext. gcm.NonceSize() возвращает 12 (стандарт GCM). io.ReadFull(rand.Reader, nonce) гарантирует, что nonce заполнен энтропией полностью, а не частично.
Почему AES-GCM:
- Authenticated encryption — GCM встроенно проверяет целостность (authentication tag). Без MAC атакующий может изменить ciphertext, а decrypt вернёт мусор, неотличимый от правильного ответа.
- Без padding — нет padding-oracle-атак (как в CBC).
- Nonce 12 байт — стандарт для GCM; не 16, как в CBC.
Nonce reuse в GCM полностью ломает защиту: атакующий получает XOR двух открытых текстов. Именно поэтому новый nonce на каждый encrypt через io.ReadFull(rand.Reader, ...).
Пример интеграции в Product-сервис — хранение зашифрованного номера карты покупателя при оформлении заказа:
// adapters/out/postgres/order_repo.go
func (r *orderRepo) SaveOrderWithCard(ctx context.Context, order Order, rawCard string) error {
encrypted, err := EncryptPaymentData(r.encryptionKey, []byte(rawCard))
if err != nil {
return fmt.Errorf("save order: %w", err)
}
_, err = r.db.Exec(ctx, `
INSERT INTO orders (id, customer_id, encrypted_card)
VALUES ($1, $2, $3)
`, order.ID, order.CustomerID, encrypted)
return err
}
TLS минимум 1.2
R-SEC-CRYPTO-4: TLS ≥ 1.2.
Go 1.21+ по умолчанию поддерживает TLS 1.2/1.3. Если TLS терминируется на уровне приложения (а не на reverse-proxy), явно устанавливается MinVersion:
// adapters/in/http/server.go
import "crypto/tls"
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
srv := &http.Server{
Addr: ":8443",
Handler: router,
TLSConfig: tlsCfg,
}
В UCP-сервисах TLS обычно терминируется на reverse-proxy (nginx, Caddy, Envoy). Сервис получает plain HTTP внутри кластера — proxy конфигурируется:
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
TLS 1.0/1.1 содержат известные уязвимости (BEAST, POODLE). Никогда не включать.
JWT — только golang-jwt с JWKS keyfunc
R-SEC-CRYPTO-5: верификация с проверкой подписи обязательна.
// adapters/in/http/middleware/jwt.go
package middleware
import (
"fmt"
"net/http"
"github.com/golang-jwt/jwt/v5"
"github.com/MicahParks/keyfunc/v3"
)
func JWTMiddleware(jwksURL, issuer, audience string) func(http.Handler) http.Handler {
jwks, err := keyfunc.NewDefault([]string{jwksURL})
if err != nil {
panic(fmt.Sprintf("init jwks: %v", err))
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := extractBearer(r)
if raw == "" {
writeUnauthorized(w)
return
}
token, err := jwt.Parse(raw, jwks.Keyfunc,
jwt.WithExpirationRequired(),
jwt.WithValidMethods([]string{"RS256", "ES256"}),
jwt.WithIssuer(issuer),
jwt.WithAudience(audience),
)
if err != nil || !token.Valid {
writeUnauthorized(w)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
writeUnauthorized(w)
return
}
next.ServeHTTP(w, r.WithContext(WithClaims(r.Context(), claims)))
})
}
}
jwt.WithValidMethods([]string{"RS256", "ES256"}) — обязательно. Без него атакующий может прислать JWT с "alg": "none", и jwt.Parse примет его без подписи. keyfunc.NewDefault кэширует JWKS и обновляет при ротации ключей.
// КАТАСТРОФА — ручной парсинг без верификации подписи
parts := strings.Split(token, ".")
payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
// ...не проверяет подпись — любой подделанный JWT пройдёт
Hardcoded ключи — запрещены
R-SEC-CRYPTO-X1:
// КАТАСТРОФА — gosec G101
const encryptionKey = "MySuperSecretKey12345678901234"
var jwtSecret = []byte("hardcoded-jwt-secret")
Любой с доступом к репозиторию или бинарному файлу имеет ключ. Ключ шифрования, подписи JWT, API-токен — никогда не в коде.
Правильно — ключи через envconfig:
// config/config.go
package config
import (
"encoding/base64"
"fmt"
"github.com/kelseyhightower/envconfig"
)
type Config struct {
EncryptionKeyBase64 string `envconfig:"ENCRYPTION_KEY_BASE64,required"`
JWTSecret string `envconfig:"JWT_SECRET,required"`
}
func (c Config) EncryptionKey() ([]byte, error) {
key, err := base64.StdEncoding.DecodeString(c.EncryptionKeyBase64)
if err != nil {
return nil, fmt.Errorf("decode encryption key: %w", err)
}
return key, nil
}
func Load() (Config, error) {
var c Config
return c, envconfig.Process("", &c)
}
Локально — .env (в .gitignore); prod — Vault, k8s Secret, AWS Secrets Manager. Nonce не хранится и не захардкоживается — генерируется io.ReadFull(rand.Reader, nonce) на каждое шифрование.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
crypto/md5, crypto/sha1, crypto/sha256 без KDF для паролей | R-SEC-CRYPTO-1 | golang.org/x/crypto/bcrypt cost ≥ 12 |
| BCrypt cost < 12 | R-SEC-CRYPTO-1 | минимум 12 в 2026 |
math/rand для токенов, nonce, ключей | R-SEC-CRYPTO-2 | crypto/rand |
| AES-ECB или AES-CBC без MAC | R-SEC-CRYPTO-3 | crypto/cipher AES-GCM |
| Фиксированный или повторно используемый nonce в GCM | R-SEC-CRYPTO-3 | io.ReadFull(rand.Reader, nonce) на каждый encrypt |
| TLS 1.0/1.1 | R-SEC-CRYPTO-4 | tls.VersionTLS12 минимум |
| Ручной парсинг JWT без верификации подписи | R-SEC-CRYPTO-5 | golang-jwt/jwt/v5 + JWKS keyfunc |
JWT без WithValidMethods | R-SEC-CRYPTO-5 | jwt.WithValidMethods([]string{"RS256", "ES256"}) |
| Hardcoded ключ или nonce в коде | R-SEC-CRYPTO-X1 | envconfig + Vault/KMS |
| Свой кастомный crypto-алгоритм | R-SEC-CRYPTO-1 | crypto/aes + crypto/cipher + golang.org/x/crypto |
Куда дальше
- SAST по коду — gosec G401/G501/G404 блокируют слабую криптографию на PR-gate.
- Секреты в коде и истории — ключи через envconfig, а не в коде.
- Реакция на findings — SLA по severity и suppressions со сроком.
- Container/image-уязвимости — TLS на reverse-proxy, distroless-образ.
- CVE в зависимостях — govulncheck для
golang.org/x/cryptoи других зависимостей.