5. Подписи и HMAC
Цифровые подписи и HMAC: аутентификация данных, целостность и механическое сочувствие
«Честность без знаний немощна и бесполезна, а знания без честности опасны и разрушительны».
— Сэмюэл Джонсон
В распределенных системах и микросервисной архитектуре просто зашифровать данные — недостаточно. Если атакующий перехватит зашифрованный пакет и изменит в нем несколько байтов, наивный алгоритм расшифрует мусор и попытается передать его бизнес-логике. Еще опаснее ситуация, когда данные передаются в открытом виде (например, вебхуки от платежных систем Stripe или GitHub): как серверу убедиться, что HTTP-запрос пришел именно от платежного шлюза, а не от злоумышленника?
Для решения этой фундаментальной задачи существуют два математических примитива:
- HMAC (Hash-based Message Authentication Code): Симметричный механизм аутентификации. Обе стороны делят общий секретный ключ. Это похоже на оттиск фамильного перстня-печатки на сургуче: проверить подлинность может только тот, кто держит в руках точно такой же перстень.
- Цифровые подписи (Digital Signatures: Ed25519, ECDSA, RSA-PSS): Асимметричный механизм. Подпись создается владельцем приватного ключа, а проверить ее может любой желающий с помощью публичного ключа. Это аналог нотариально заверенной подписи на договоре: подпись уникальна, ее невозможно подделать без приватного ключа, а автор не может заявить: «Я этого не подписывал» (свойство неотказуемости / Non-repudiation).
Фундамент аутентификации данных: HMAC и цифровые подписи
flowchart TD
Data["Исходные данные (Webhook, токен, транзакция)"]
Data --> HMAC_Branch["HMAC: Симметричный секрет"]
Data --> Sign_Branch["Цифровая подпись: Асимметричная пара"]
HMAC_Branch --> CalcHMAC["Вычисление: H((K ⊕ opad) || H((K ⊕ ipad) || m))"]
CalcHMAC --> MAC_Result["Результат: Фиксированный дайджест (32 байта)"]
Sign_Branch --> HashSign["Хеширование сообщения (SHA-512)"]
HashSign --> AsymSign["Сжатие и скалярное умножение на кривой"]
AsymSign --> Sign_Result["Результат: Цифровая подпись (64 байта)"]
MAC_Result --> VerifyHMAC["Верификация: Вычисление и ConstantTimeCompare"]
Sign_Result --> VerifySign["Верификация: Проверка публичным ключом"]
style Data fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
style CalcHMAC fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc
style AsymSign fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
style VerifyHMAC fill:#1e293b,stroke:#6366f1,stroke-width:2px,color:#f8fafc
style VerifySign fill:#1e293b,stroke:#ec4899,stroke-width:2px,color:#f8fafc
HMAC: Симметричная целостность и конструкция
Конструкция HMAC стандартизирована в RFC 2104 и математически выражается формулой:
$$\text{HMAC}(K, m) = H((K \oplus \text{opad}) \mathbin{\Vert} H((K \oplus \text{ipad}) \mathbin{\Vert} m))$$
(где HMAC(K, m) = H((K ⊕ opad) || H((K ⊕ ipad) || m))).
Почему разработчики криптографических стандартов не использовали простую формулу H(K || m) или H(m || K)?
- Атаки расширения длины (Length Extension Attacks):
Классические хеш-функции семействаMerkle-Damgård(SHA-1,SHA-256) обрабатывают данные блоками по 64 байта. Финальный хеш — это просто внутреннее состояние регистров процессора после последнего блока. Если сервер проверяет аутентичность наивным выражениемH(K || m), злоумышленник, зная длину ключа и видя хеш в сети, может дописать свои вредоносные данные $m'$ и продолжить вычисление хеша вообще не зная секретного ключа $K$! - Двойное перемешивание (
opadиipad):
HMAC использует две константы: внутреннюю маскуipad(0x36) и внешнюю маскуopad(0x5c). Ключ XOR-ится с масками, выполняя двойное вложенное хеширование. Это полностью нейтрализует атаки расширения длины.
В рантайме Go пакет crypto/hmac реализован на чистом Go с ассемблерным ускорением внутренних функций хеширования (crypto/sha256).
package auth
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
)
// ComputeHMAC генерирует код аутентификации сообщения
func ComputeHMAC(data, key []byte) ([]byte, error) {
if len(key) == 0 {
return nil, errors.New("hmac key must not be empty")
}
mac := hmac.New(sha256.New, key)
if _, err := mac.Write(data); err != nil {
return nil, fmt.Errorf("hmac write: %w", err)
}
return mac.Sum(nil), nil
}
// VerifyHMAC безопасно проверяет подпись
func VerifyHMAC(data, key, macHex []byte) error {
expected, err := ComputeHMAC(data, key)
if err != nil {
return fmt.Errorf("compute hmac: %w", err)
}
got, err := hex.DecodeString(string(macHex))
if err != nil || len(got) != len(expected) {
return errors.New("invalid mac format")
}
// 🔒 Constant-time comparison: защита от Timing Attacks!
if subtle.ConstantTimeCompare(expected, got) != 1 {
return errors.New("invalid hmac signature")
}
return nil
}
Цифровые подписи: Асимметрия и неотказуемость
В современной практике бэкенда применяются три ключевых алгоритма цифровой подписи:
RSA-PSS(Probabilistic Signature Scheme):
Математически строгий стандарт на базе RSA. В отличие от старого PKCS#1 v1.5, схема PSS вероятностная (добавляет случайную соль). Однако RSA требует огромных ключей (2048–4096 бит), а вычисления в Go опираются на пакетmath/big, создавая существенную нагрузку наGC.ECDSA(NIST P-256 / P-384):
Алгоритм на базе эллиптических кривых. Компактнее и быстрее RSA. Реализован в пакетеcrypto/ecdsa. Исторически содержал вариативность по времени в операциях сbig.Int, но в современных версиях Go опирается на внутренний оптимизированный пакетcrypto/internal/nistec.Ed25519(EdDSA на кривой Curve25519):
Безусловный лидер современной криптографии. Он детерминирован, защищен от атак по времени, работает с фиксированными 32-байтовыми ключами и выдает 64-байтовую подпись. В Go пакетcrypto/ed25519содержит оптимизированные ассемблерные вставки подamd64иarm64.
package signatures
import (
"crypto/ed25519"
"crypto/rand"
"errors"
"fmt"
)
// GenerateEd25519KeyPair создаёт пару криптографических ключей
func GenerateEd25519KeyPair() (ed25519.PrivateKey, ed25519.PublicKey, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
return priv, pub, nil
}
// SignData подписывает произвольные данные приватным ключом Ed25519
func SignData(priv ed25519.PrivateKey, data []byte) ([]byte, error) {
// 🔒 Под капотом: хеширование SHA-512 и детерминированное
// скалярное умножение на кривой Edwards25519 в константном времени
signature := ed25519.Sign(priv, data)
return signature, nil
}
// VerifySignature проверяет цифровую подпись публичным ключом
func VerifySignature(pub ed25519.PublicKey, data, signature []byte) error {
if len(pub) != ed25519.PublicKeySize || len(signature) != ed25519.SignatureSize {
return errors.New("invalid key or signature length")
}
if !ed25519.Verify(pub, data, signature) {
return errors.New("invalid ed25519 signature")
}
return nil
}
Механическое сочувствие: Влияние на CPU, кэш и GC
Сравним физические затраты ресурсов на верификацию:
| Критерий | HMAC-SHA256 | Ed25519 | ECDSA P-256 | RSA-2048 |
|---|---|---|---|---|
| Пропускная способность | ~500–800 МБ/с на ядро | ~150–200 тыс. оп/с | ~50–80 тыс. оп/с | ~5–10 тыс. оп/с |
| Аллокации в памяти | Минимальные (буфер хеша) | Минимальные (массивы фиксированной длины) | Средние (big.Int координаты точек) |
Высокие (big.Int модульные возведения) |
| Поведение в кэше CPU | Линейное, предсказуемое | Оптимизировано, регистровые скаляры | Переменное (зависит от кривой) | Хаотичное (сбросы конвейера) |
| Давление на сборщик мусора | Нулевое | Низкое | Среднее (Minor GC) |
Высокое |
| Размер подписи | 32 байта | 64 байта | 64–72 байта (ASN.1 DER) | 256 байт |
В рантайме Go пакет crypto/ed25519 полностью избегает аллокаций в куче благодаря фиксированным типам массивов [32]byte и [64]byte. Вся арифметика на кривой выполняется в процессорных регистрах и на стеке горутины, что минимизирует промахи кэша. Напротив, crypto/ecdsa и crypto/rsa вынуждены оперировать точками (x, y) и структурами big.Int, что при тысячах проверок в секунду провоцирует частые паузы Minor GC.
Интеграция в продакшене: Верификация вебхуков
Классический сценарий применения HMAC в бэкенд-разработке — валидация входящих вебхуков (Stripe, GitHub, Slack):
- Сервер извлекает заголовок
X-Signatureи сырое тело HTTP-запросаreqBody. - Из заголовка извлекается временная метка
t=...(timestamp) и подписьs=.... - Проверяется допустимый дрейф времени (не более 5 минут) для защиты от атак повторного воспроизведения (Replay Attacks).
- Вычисляется
HMAC(secret, t.signed_payload)от конкатенации временной метки и сырого тела запроса. - Результат валидируется функцией
crypto/subtle.ConstantTimeCompare.
package auth
import (
"errors"
"fmt"
"time"
)
func VerifyWebhook(reqBody []byte, sigHeader string, secret []byte) error {
// Формат заголовка: "t=1234567890,v1=abcdef..."
var timestamp int64
var signatureHex []byte
// ... парсинг временной метки и подписи ...
// Защита от Replay Attack: не более 5 минут разницы
if time.Now().Unix()-timestamp > 300 {
return errors.New("webhook expired: timestamp skew exceeds 5 minutes")
}
// Конкатенация с разделением
payload := fmt.Sprintf("%d.%s", timestamp, string(reqBody))
return VerifyHMAC([]byte(payload), secret, signatureHex)
}
Итог
- HMAC обеспечивает сверхбыструю симметричную проверку целостности и подлинности данных ($O(1)$, ГБ/с), идеально подходя для внутренней микросервисной коммуникации и вебхуков.
- Цифровые подписи решают проблему доверия в открытой среде и гарантируют неотказуемость авторства.
Ed25519— современный золотой стандарт благодаря детерминизму, компактности и защите от атак по времени. - Механическое сочувствие:
crypto/ed25519в Go не используетbig.Int, выполняя всю математику в регистрах CPU и минимизируя аллокации в куче. - Безопасность сравнения: Любая валидация кодов аутентификации обязана использовать
crypto/subtle.ConstantTimeCompareдля исключения timing-атак. - Дисциплина ключей: Применяйте Domain Separation (разделение контекстов) и деривацию через
HKDF-SHA256для исключения перекрестных атак.
Как правильно генерировать криптографические ключи, токены и соли, не допуская критических ошибок энтропии?
В следующей лекции мы детально разберем генерацию случайных данных: 6. Генерация случайных данных.