Опирается на правила: R-SEC-CRYPTO-1R-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). gosec G404 ловит 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-1golang.org/x/crypto/bcrypt cost ≥ 12
BCrypt cost < 12R-SEC-CRYPTO-1минимум 12 в 2026
math/rand для токенов, nonce, ключейR-SEC-CRYPTO-2crypto/rand
AES-ECB или AES-CBC без MACR-SEC-CRYPTO-3crypto/cipher AES-GCM
Фиксированный или повторно используемый nonce в GCMR-SEC-CRYPTO-3io.ReadFull(rand.Reader, nonce) на каждый encrypt
TLS 1.0/1.1R-SEC-CRYPTO-4tls.VersionTLS12 минимум
Ручной парсинг JWT без верификации подписиR-SEC-CRYPTO-5golang-jwt/jwt/v5 + JWKS keyfunc
JWT без WithValidMethodsR-SEC-CRYPTO-5jwt.WithValidMethods([]string{"RS256", "ES256"})
Hardcoded ключ или nonce в кодеR-SEC-CRYPTO-X1envconfig + Vault/KMS
Свой кастомный crypto-алгоритмR-SEC-CRYPTO-1crypto/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 и других зависимостей.