Криптография Web3: хеши, пары ключей, подписи, адреса
В блокчейне нет базы пользователей, нет пароля, нет «восстановить доступ по email» и нет администратора, который подтвердит, что вы — это вы. Есть ровно одно утверждение, которое сеть умеет проверять механически: эта запись сопровождается корректной цифровой подписью, значит её автор знал приватный ключ, соответствующий такому-то адресу. Всё остальное — надстройка над этим фактом.
Отсюда следует неприятное, но честное следствие: в Web3 криптография — не «слой безопасности», который навешивают позже. Это единственный механизм авторизации вообще. Если вы посчитали не тот хеш, забыли добавить chainId в подписываемые данные или не проверили результат ecrecover на ноль — вы не «ослабили защиту», вы отдали контроль над средствами. Разница между «пользователь потерял пароль» и «пользователь потерял приватный ключ» такая же, как между «забыл код от сейфа» и «сейф перестал существовать».
Статья продолжает Как устроен блокчейн, где хеш-цепочка и дерево Меркла появились как структуры данных. Здесь мы разбираем сами примитивы: что гарантирует хеш-функция, откуда берётся пара ключей, что физически происходит при подписи транзакции и какие ошибки в этом коде встречаются в проде. Математическая база — группы и конечные поля — разобрана в Абстрактной алгебре; отсюда возьмём необходимый минимум. Сразу оговорка о жанре: дальше не будет ни слова о том, что покупать и куда пойдёт цена — только механика и то, как её ломают.
Три примитива и что каждый из них реально гарантирует
Web3 держится на трёх криптографических кирпичах. Их постоянно путают, и путаница дорого стоит.
| Примитив | Что даёт | Чего НЕ даёт | Где в Ethereum |
|---|---|---|---|
| Криптографический хеш | целостность, компактный идентификатор данных | секретность, обратимость, доказательство авторства | keccak256, ID транзакций, дерево Меркла, слоты storage |
| Пара ключей (ECDSA/secp256k1) | связку «секрет ↔ публичная личность» | конфиденциальность данных | адреса, ecrecover |
| Цифровая подпись | аутентичность, целостность, неотказуемость | секретность подписанного, защиту от повтора | транзакции, EIP-712, permit |
Два тезиса подчеркну отдельно, потому что они регулярно нарушаются в реальном коде. Хеш — это не шифрование: он ничего не скрывает, и если пространство входов маленькое (адрес, число от 0 до 1000, ответ «да/нет»), его перебирают за секунды; хранить в контракте keccak256(ставка) и считать ставку тайной — самообман. Подпись не защищает от повтора: валидная подпись остаётся валидной вечно, и если в подписанные данные не включены nonce, chainId, адрес контракта и срок действия, одна и та же подпись сработает дважды, в другой сети и в другом контракте.
Хеш-функции: договор, который вы принимаете, не читая
Криптографическая хеш-функция H: {0,1}* -> {0,1}^n отображает произвольные данные в фиксированные n бит и обязана удовлетворять трём свойствам:
Устойчивость к прообразу : по h найти любое m с H(m) = h — невозможно
Устойчивость ко второму прообразу: по m1 найти m2 != m1 с H(m2) = H(m1) — невозможно
Устойчивость к коллизиям : найти любую пару m1 != m2 с H(m1) = H(m2) — невозможно
«Невозможно» здесь вычислительное: лучшая известная атака требует порядка 2^n операций для прообраза и 2^(n/2) для коллизии (парадокс дней рождения). Для 256-битного выхода это 2^256 и 2^128 — недостижимо. Отсюда, кстати, ответ на вопрос «почему адрес всего 160 бит»: коллизия адреса стоит 2^80 операций — дорого, но уже не астрономически, и потому 160 бит используются только там, где коллизия сама по себе мало что даёт атакующему. Четвёртое, неформальное свойство — лавинный эффект: изменение одного бита входа меняет примерно половину битов выхода. Именно оно делает хеш-цепочку блокчейна необратимой в смысле «нельзя незаметно подправить старый блок».
Keccak-256 — это не SHA3-256
Классическая ловушка. Ethereum зафиксировал хеш-функцию в 2015 году, до финализации стандарта NIST FIPS 202. В процессе стандартизации NIST изменил байт паддинга: оригинальный Keccak использует 0x01, стандартизованный SHA-3 — 0x06. Внутренняя перестановка та же, результат — разный.
keccak256("") = c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
sha3_256("") = a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a
Практический вывод: в Python hashlib.sha3_256 не подходит для Ethereum, нужен pycryptodome (Crypto.Hash.keccak), eth-hash или pysha3. Каждый год кто-то теряет день на отладку «почему у меня адрес не совпадает».
from Crypto.Hash import keccak # pip install pycryptodome
def keccak256(data: bytes) -> bytes:
"""Keccak-256 в варианте Ethereum (паддинг 0x01), а НЕ SHA3-256."""
h = keccak.new(digest_bits=256)
h.update(data)
return h.digest()
# Селектор функции — первые 4 байта хеша её сигнатуры.
# Именно так EVM понимает, какой метод контракта вызывают.
print(keccak256(b"transfer(address,uint256)")[:4].hex()) # a9059cbb
Сложность вычисления — O(len) по времени и O(1) по дополнительной памяти (состояние губки — 1600 бит). В EVM KECCAK256 стоит 30 газа плюс 6 за каждые 32 байта входа — одна из самых дешёвых нетривиальных операций, поэтому keccak в Solidity используют буквально везде. Для сравнения: прекомпайл sha256 по адресу 0x02 стоит 60 + 12 за слово, а ripemd160 по адресу 0x03 — 600 + 120 за слово. Экономика газа разобрана в Ethereum и EVM.
Где хеши ломаются в прикладном коде
Ошибка 1: abi.encodePacked с двумя динамическими аргументами. Упакованное кодирование не хранит длины, поэтому границы полей теряются: keccak256(abi.encodePacked("a", "bc")) равно keccak256(abi.encodePacked("ab", "c")) — обе пары дают "abc". Если по такому хешу выдаётся подпись «разрешаю пользователю X действие Y», атакующий сдвигает границу и получает подпись на другую пару. Правило: два и более динамических аргумента — только abi.encode, оно кодирует длины.
Ошибка 2: хеш как «сокрытие». Схема commit-reveal (сначала публикуем H(выбор), потом раскрываем) работает только с солью достаточной энтропии:
bytes32 bad = keccak256(abi.encode(choice)); // ❌ вариантов два, перебор мгновенный
bytes32 good = keccak256(abi.encode(choice, salt, msg.sender)); // ✅ 32 байта соли + привязка к автору
Добавление msg.sender — не косметика: без него другой участник просто скопирует чужой коммит и всегда сыграет «в ничью».
Ошибка 3: хеш вместо KDF. Для превращения пароля в ключ голый хеш не годится — он слишком быстрый. Keystore-файлы Ethereum (формат V3) используют scrypt или PBKDF2 именно потому, что нужна намеренная медленность и требовательность к памяти. Подробнее — в Кошельках и ключах.
Асимметричные ключи: односторонняя дверь на эллиптической кривой
Симметричное шифрование требует общего секрета, а в открытой сети незнакомцев его негде взять. Асимметричная криптография решает это через функцию с потайным ходом: посчитать в одну сторону легко, обратно — нет, а обладатель секрета получает короткий путь. В Bitcoin и Ethereum используется кривая secp256k1 над конечным полем:
y² = x³ + 7 (mod p)
p = 2²⁵⁶ − 2³² − 977
= FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFE FFFFFC2F
n = FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFE BAAEDCE6 AF48A03B BFD25E8C D0364141 ← порядок группы
Gx = 79BE667E F9DCBBAC 55A06295 CE870B07 029BFCDB 2DCE28D9 59F2815B 16F81798
Gy = 483ADA77 26A3C465 5DA4FBFC 0E1108A8 FD17B448 A6855419 9C47D08F FB10D4B8
Точки кривой с операцией «сложение точек» образуют циклическую группу порядка n с генератором G. Приватный ключ — просто целое число d в диапазоне 1 ≤ d < n; публичный ключ — точка Q = d · G. Односторонность называется задачей дискретного логарифма на эллиптической кривой (ECDLP): зная G и Q, найти d. Лучший известный общий алгоритм — ро-метод Полларда со сложностью примерно 2^(бит/2), то есть 2^128 для secp256k1. Поэтому 256-битный ключ на кривой по стойкости примерно эквивалентен 3072-битному RSA: на кривых нет субэкспоненциальных методов вроде решета числового поля.
Скалярное умножение своими руками
Наивное «сложить G саму с собой d раз» потребовало бы 2^256 операций. Работает double-and-add — тот же приём, что и быстрое возведение в степень.
p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
def inv(a: int, m: int = p) -> int:
return pow(a, -1, m) # Python 3.8+
def point_add(P, Q):
"""Сложение точек y² = x³ + 7. None — точка на бесконечности O."""
if P is None: return Q
if Q is None: return P
(x1, y1), (x2, y2) = P, Q
if x1 == x2 and (y1 + y2) % p == 0:
return None # P + (−P) = O
lam = ((3 * x1 * x1) * inv(2 * y1) if P == Q # удвоение; a = 0 у secp256k1
else (y2 - y1) * inv(x2 - x1)) % p
x3 = (lam * lam - x1 - x2) % p
return (x3, (lam * (x1 - x3) - y1) % p)
def point_mul(k: int, P=G):
"""double-and-add: O(log k) сложений точек, O(1) дополнительной памяти."""
R = None
while k:
if k & 1:
R = point_add(R, P)
P = point_add(P, P)
k >>= 1
return R
Сложность. point_mul делает O(log k) сложений точек; каждое — несколько умножений в поле плюс одно обращение (через малую теорему Ферма это O(log p) умножений). Итого порядка log k · log p умножений 256-битных чисел при O(1) памяти. И категорически важная оговорка: этот код годится для понимания и непригоден для продакшна: ветка if k & 1 выполняется в зависимости от бита секрета, значит время работы и энергопотребление зависят от ключа — классический канал утечки по времени. Реальные библиотеки (libsecp256k1, noble-secp256k1) используют лестницу Монтгомери или wNAF с фиксированным паттерном операций и ослепление скаляра. Своя криптография в проде — почти всегда ошибка.
Форматы публичного ключа
Точка Q = (X, Y) — это 64 байта. Но Y восстанавливается из X решением y² = x³ + 7, а решений ровно два, и они различаются чётностью. Отсюда две формы:
несжатая: 0x04 ‖ X(32) ‖ Y(32) = 65 байт
сжатая: 0x02 ‖ X(32) = 33 байта, если Y чётный
0x03 ‖ X(32) = 33 байта, если Y нечётный
Bitcoin давно использует сжатую форму (экономия места в блоках). Ethereum при выводе адреса берёт несжатые координаты без префикса 0x04 — ровно 64 байта.
Адрес: хеш публичного ключа, а не сам ключ
Ethereum-адрес — это последние 20 байт от keccak256 публичного ключа:
def address_from_private(d: int) -> str:
assert 1 <= d < n, "ключ вне диапазона порядка группы"
X, Y = point_mul(d)
pub = X.to_bytes(32, "big") + Y.to_bytes(32, "big") # 64 байта, префикс не берём
return "0x" + keccak256(pub)[-20:].hex()
print(address_from_private(1)) # 0x7e5f4552091a69125d5dfcb7b8c2659029395bdf
То, что адрес — хеш ключа, а не сам ключ, даёт два неочевидных следствия. Во-первых, публичный ключ адреса, с которого ещё не уходило транзакций, не известен никому: он раскрывается только в момент первой исходящей транзакции, потому что подпись позволяет его восстановить. Во-вторых, адрес не содержит контрольной суммы сам по себе — опечатка в hex даст другой, полностью валидный адрес, на который средства уйдут безвозвратно. Затычка — EIP-55: контрольная сумма кодируется регистром букв.
def to_checksum_address(addr: str) -> str:
"""EIP-55: хешируется ASCII-строка hex в нижнем регистре, а НЕ байты адреса."""
a = addr.lower().removeprefix("0x")
h = keccak256(a.encode("ascii")).hex()
return "0x" + "".join(
c.upper() if c.isalpha() and int(h[i], 16) >= 8 else c
for i, c in enumerate(a)
)
print(to_checksum_address("0x7e5f4552091a69125d5dfcb7b8c2659029395bdf"))
# 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
Каждая буква несёт один бит контроля; в среднем адресе букв около пятнадцати, то есть вероятность пропустить опечатку — примерно 1 к 30 000. Заметно лучше, чем ничего, но принципиально слабее, чем Base58Check в Bitcoin (4 байта контрольной суммы) или bech32 (BCH-код, который ещё и указывает позицию ошибки). Для сравнения, как адрес выводится в разных сетях:
Ethereum : addr = keccak256(X ‖ Y)[12:] — 20 байт, hex + EIP-55
Bitcoin : HASH160 = ripemd160(sha256(pubkey)) — 20 байт
P2PKH = base58check(0x00 ‖ HASH160) — "1..."
SegWit = bech32(version, HASH160) — "bc1q..."
Solana : addr = сам публичный ключ ed25519 в base58 — 32 байта, без хеширования
Двойное хеширование в Bitcoin (sha256(sha256(x)), ripemd160(sha256(x))) — историческое решение Сатоши: оно даёт защиту от атак удлинением сообщения на конструкции Меркла–Дамгора, которой у губки Keccak нет по устройству.
Адреса контрактов: детерминизм и его цена
У контрактов приватных ключей нет вообще, их адреса вычисляются:
CREATE : addr = keccak256(rlp([sender, nonce]))[12:]
CREATE2 : addr = keccak256(0xff ‖ sender ‖ salt ‖ keccak256(init_code))[12:]
CREATE2 (EIP-1014) даёт предсказуемый адрес до деплоя — на этом стоят фабрики пулов, счета-абстракции и «контрфактические» кошельки, адрес которых известен раньше, чем контракт существует.
function predictAddress(bytes32 salt, bytes memory initCode) public view returns (address) {
return address(uint160(uint256(keccak256(
abi.encodePacked(bytes1(0xff), address(this), salt, keccak256(initCode))))));
}
Честно про риск: до апгрейда Cancun пара CREATE2 + selfdestruct позволяла делать «метаморфические» контракты — по одному адресу последовательно жил разный код. Пользователь, проверивший исходники и выдавший approve, мог обнаружить, что по адресу теперь другой контракт. EIP-6780 ограничил selfdestruct случаем «создан и уничтожен в одной транзакции», что закрыло основную схему, но привычку проверять историю адреса, а не только текущий байткод, стоит сохранить.
Подпись ECDSA: механика и её острые углы
Подпись дайджеста z ключом d — это выбор эфемерного секрета k (самый опасный шаг), вычисление точки R = k · G и пары r = R.x mod n, s = k⁻¹ · (z + r · d) mod n. Проверка приватного ключа не требует вовсе: она восстанавливает R из (z, r, s) и публичного ключа Q.
def ecdsa_sign(z: int, d: int, k: int):
"""k ОБЯЗАН быть уникальным и непредсказуемым для каждой подписи."""
r = point_mul(k)[0] % n
s = (inv(k, n) * (z + r * d)) % n
# EIP-2: канонизируем подпись. Внимание: при замене s на n − s
# обязан перевернуться и recovery id v, иначе ecrecover вернёт чужой адрес.
return r, min(s, n - s)
def ecdsa_verify(z: int, r: int, s: int, Q) -> bool:
if not (1 <= r < n and 1 <= s < n):
return False
w = inv(s, n)
R1 = point_add(point_mul(z * w % n, G), point_mul(r * w % n, Q))
return R1 is not None and R1[0] % n == r
Почему в Ethereum подпись 65 байт, а не 64
Из (r, s) можно не просто проверить подпись, а восстановить публичный ключ. Точка R восстанавливается из r (это её x-координата), но кандидатов несколько: два по чётности Y и ещё два на случай R.x ≥ n. Дополнительный байт v — recovery id — устраняет неоднозначность. Это ровно то, что делает ecrecover (прекомпайл 0x01, 3000 газа). Благодаря ему в транзакции Ethereum нет поля from: отправитель не заявляется, а выводится из подписи. Подделать from невозможно не потому, что кто-то проверяет, а потому, что заявлять нечего.
Провал первый: повторное использование k
Если одним ключом подписаны два разных сообщения с одинаковым k, приватный ключ восстанавливается арифметикой уровня школы. Признак виден снаружи: у обеих подписей совпадает r.
s1 = k⁻¹(z1 + r·d), s2 = k⁻¹(z2 + r·d)
s1 − s2 = k⁻¹(z1 − z2) ⟹ k = (z1 − z2) / (s1 − s2) mod n ⟹ d = (s1·k − z1) / r mod n
Это четыре строки Python и это не теория. В 2010 году на 27C3 команда fail0verflow показала, что Sony подписывала прошивки PlayStation 3 фиксированным k, и извлекла мастер-ключ консоли. В августе 2013 года баг в SecureRandom на Android приводил к повторяющимся k, и биткойн-кошельки на затронутых устройствах опустошались скриптами, которые просто сканировали блокчейн на одинаковые r (bitcoin.org/en/alert/2013-08-11-android).
Лечение — RFC 6979: k выводят детерминированно через HMAC-DRBG из (d, z), а не берут из генератора случайных чисел. Одинаковый k тогда возможен только при одинаковом сообщении и ключе, а это и так одна и та же подпись. Реализация есть в libsecp256k1, python-ecdsa, noble-secp256k1. Схема EdDSA (ed25519) делает то же самое на уровне спецификации — в этом её главное практическое преимущество перед ECDSA.
Провал второй: пластичность подписи
Если (r, s) — валидная подпись, то (r, n − s) тоже валидна: у −R та же x-координата. То есть у одного сообщения есть две разные корректные подписи. Для транзакций это исторически ломало отслеживание по хешу (история Mt. Gox 2014 года), и в Ethereum лечится протоколом: EIP-2 запрещает s > n/2. Но внутри контрактов это живой класс багов: если контракт помечает подписи «использованными» по хешу самой подписи, атакующий переворачивает s и получает вторую «неиспользованную» подпись на то же действие.
mapping(bytes32 => bool) public used;
require(!used[keccak256(sig)], "replay"); // ❌ ключ дедупликации можно легально изменить
mapping(address => uint256) public nonces; // ✅ дедупликация по nonce подписанта
Провал третий: ecrecover возвращает ноль
ecrecover в Solidity не бросает исключение при некорректной подписи — он возвращает address(0). Если проверяемый адрес по какой-то причине тоже нулевой (неинициализированная переменная, дефолт маппинга, «сброшенный» владелец), проверка проходит с мусорной подписью.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SignatureTraps {
address public signer;
/// ❌ Четыре бага: нет проверки на address(0); нет nonce (подпись вечная); нет chainId
/// и адреса контракта (replay); encodePacked с двумя динамическими полями (коллизия).
function claimBroken(address to, string calldata tag, bytes calldata note,
uint8 v, bytes32 r, bytes32 s) external view returns (bool) {
return ecrecover(keccak256(abi.encodePacked(to, tag, note)), v, r, s) == signer;
}
/// ✅ Безопасное низкоуровневое восстановление адреса.
function _recover(bytes32 digest, bytes calldata sig) internal pure returns (address) {
require(sig.length == 65, "bad signature length");
bytes32 r; bytes32 s; uint8 v;
assembly {
r := calldataload(sig.offset)
s := calldataload(add(sig.offset, 32))
v := byte(0, calldataload(add(sig.offset, 64)))
}
// EIP-2: верхняя половина порядка группы запрещена (защита от пластичности)
require(uint256(s) <=
0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0, "malleable s");
require(v == 27 || v == 28, "bad v");
address recovered = ecrecover(digest, v, r, s);
require(recovered != address(0), "invalid signature"); // ключевая строка
return recovered;
}
}
В реальном проекте не пишите это вручную — берите ECDSA из OpenZeppelin: там уже учтены компактный формат EIP-2098, пластичность и нулевой адрес.
Что подписывать: EIP-191 и EIP-712
Возникает вопрос, который выглядит бюрократическим, а на деле критичен: можно ли подписывать произвольный 32-байтный хеш? Нет. Хеш транзакции — тоже 32 байта, и сайт, попросивший «подписать строку для входа», мог бы подсунуть под видом строки дайджест транзакции, переводящей все средства. Разделение доменов решает это префиксом. EIP-191 вводит формат для человекочитаемых сообщений — "\x19Ethereum Signed Message:\n" ‖ len(message) ‖ message. Байт 0x19 выбран не случайно: RLP-кодирование транзакции никогда с него не начинается, поэтому подписанное сообщение физически не может оказаться валидной транзакцией.
Но и это полумера: пользователь видит в кошельке нечитаемую строку и жмёт «подписать». EIP-712 делает данные структурными и отображаемыми:
digest = keccak256( 0x19 ‖ 0x01 ‖ domainSeparator ‖ hashStruct(message) )
domainSeparator = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name)), keccak256(bytes(version)), block.chainid, address(this)))
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract VoucherClaim {
bytes32 private constant DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
bytes32 private constant VOUCHER_TYPEHASH = keccak256(
"Voucher(address to,uint256 amount,uint256 nonce,uint256 deadline)");
address public immutable issuer;
mapping(address => uint256) public nonces;
constructor(address issuer_) { issuer = issuer_; }
function _domainSeparator() internal view returns (bytes32) {
// считаем на лету: кешировать можно, но тогда нужен пересчёт при форке цепи
return keccak256(abi.encode(DOMAIN_TYPEHASH, keccak256(bytes("VoucherClaim")),
keccak256(bytes("1")), block.chainid, address(this)));
}
function claim(uint256 amount, uint256 deadline, bytes calldata signature) external {
require(block.timestamp <= deadline, "voucher expired");
// abi.encode, а не encodePacked: все поля фиксированной ширины, границы сохранены
bytes32 structHash = keccak256(abi.encode(
VOUCHER_TYPEHASH, msg.sender, amount, nonces[msg.sender]++, deadline));
bytes32 digest = keccak256(
abi.encodePacked("\x19\x01", _domainSeparator(), structHash));
require(ECDSA.recover(digest, signature) == issuer, "bad voucher");
_payout(msg.sender, amount);
}
function _payout(address, uint256) internal pure { /* ... */ }
}
Каждое поле здесь закрывает конкретную атаку — это стоит держать в голове как чек-лист при ревью:
| Поле | Что закрывает | Что будет без него |
|---|---|---|
chainId |
replay между сетями | подпись из тестнета или форка сработает в основной сети |
verifyingContract |
replay между контрактами | подпись для v1 сработает в v2 с другой логикой |
nonce с инкрементом |
повторное применение | один ваучер обналичивается бесконечно |
deadline |
вечную валидность | забытая подпись всплывает через два года |
msg.sender в структуре |
подмену получателя | подпись перехватывается и исполняется чужим адресом |
Слепая подпись как вектор фишинга
Самый массовый способ потерять активы сегодня — не взлом контракта, а подпись, которую пользователь не понял. Типовые сценарии: Permit (EIP-2612), разрешающий контракту тратить ваши токены без транзакции и без газа; setApprovalForAll для NFT, отдающий одной подписью всю коллекцию; ордер на маркетплейсе — подпись на продажу актива за символическую цену.
Инженерный вывод для тех, кто строит dApp: всегда используйте EIP-712 и осмысленные имена типов, чтобы кошелёк показал «Permit: spender 0x…, value …, deadline …», а не hex-кашу, и проектируйте так, чтобы бесконечный approve не был единственным вариантом. Для пользователя правило простое: подпись, смысл которой нельзя прочитать в окне кошелька, эквивалентна подписи на чистом листе. Как эти подписи взаимодействуют со стандартами токенов — в Токенах и стандартах, а аудит принимающих их контрактов — в Безопасности контрактов.
Схемы подписи: чем ECDSA хуже и лучше альтернатив
| Схема | Размер подписи | Ключевое свойство | Где применяется |
|---|---|---|---|
| ECDSA / secp256k1 | 64–65 байт | восстановление публичного ключа из подписи | транзакции Bitcoin и Ethereum |
| Ed25519 | 64 байта | детерминированный nonce по спецификации, быстрая проверка | Solana, Cardano, Near, SSH |
| Schnorr (BIP-340) | 64 байта | линейность: ключи и подписи складываются в одну | Bitcoin Taproot, MuSig2 |
| BLS12-381 | 96 байт | агрегация тысяч подписей в одну константную | консенсус Ethereum, аттестации |
Линейность Schnorr и BLS — не абстракция. В консенсусе Ethereum сотни тысяч валидаторов подписывают аттестации, и без агрегации BLS блок бы физически не поместился в разумный размер: агрегированная подпись остаётся 96-байтной независимо от числа подписантов, а проверяющему нужен лишь агрегированный публичный ключ. ECDSA такой линейности лишена именно из-за k⁻¹ в формуле — это исторический артефакт обхода патентов на схему Шнорра, истёкших только в 2008 году.
Квантовая угроза: без паники и без замалчивания
Алгоритм Шора решает задачу дискретного логарифма за полиномиальное время, то есть достаточно большой отказоустойчивый квантовый компьютер выводит приватный ключ из публичного. Оценки требуемых ресурсов — миллионы физических кубитов при текущих кодах коррекции ошибок; таких машин нет, а сроки их появления никто честно предсказать не может. Алгоритм Гровера ускоряет перебор квадратично: 256-битный хеш даёт эффективные 128 бит — неприятно, но не смертельно, хеш-функции переживают квантовый переход относительно спокойно.
Практические следствия для сегодняшних решений: пока публичный ключ не раскрыт (адрес без исходящих транзакций), он защищён хешем, а не только ECDLP — старое правило Bitcoin «не переиспользовать адрес» получает дополнительный смысл. Любой адрес, с которого хоть раз уходила транзакция, имеет публичный ключ в открытом виде навсегда. Плюс существует окно атаки в мемпуле: транзакция уже подписана и видна, но ещё не в блоке, и защиты «не раскрывать ключ» здесь нет в принципе. Постквантовые схемы подписи (ML-DSA/Dilithium, SPHINCS+, стандартизованные NIST в 2024 году) дают подписи в килобайты — в блокчейне, где место в блоке стоит денег, это серьёзный экономический вопрос, а не только технический. Вывод инженерный, а не алармистский: угроза реальна и её обсуждают всерьёз, но она не повод срочно что-то менять сегодня — куда больше активов теряется от утечек seed-фраз и слепых подписей.
Хеши как адреса данных: мостик к хранилищам
Ещё одно применение хешей стоит держать в голове с самого начала. Если идентификатор объекта — это хеш его содержимого, ссылка становится самопроверяющейся: получив данные, вы пересчитываете хеш и убеждаетесь, что вам отдали именно то, что просили. Не нужно доверять серверу и не нужен TLS-сертификат — проверка встроена в саму ссылку. Это принцип контентной адресации, на котором стоят IPFS (CID), Git и Arweave; из него же следует главное ограничение — изменить содержимое, сохранив идентификатор, невозможно по определению, и «изменяемая ссылка» требует отдельного слоя имён поверх. Разбор — в Децентрализованных хранилищах.
Дерево Меркла — та же идея, свёрнутая рекурсивно. Практический приём, который вы будете писать чаще всего, — доказательство включения для списков раздач и вайтлистов: в контракте хранится один 32-байтный корень, пользователь приносит путь из log(N) хешей.
Экономика очевидна: вместо O(N) записей в storage (десятки миллионов газа) — одна запись корня и O(log N) хешей в calldata на обращение; проверка O(log N) по времени и O(1) по storage. Ловушка, о которой забывают: листья должны хешироваться иначе, чем внутренние узлы (например, двойным хешированием листа), иначе внутренний узел можно предъявить как валидный лист — классическая second-preimage атака на деревья Меркла.
Чек-лист для код-ревью
Хеши [ ] keccak256, а не sha3_256 (проверить библиотеку на бэкенде)
[ ] abi.encode вместо abi.encodePacked при двух и более динамических аргументах
[ ] соль и msg.sender в commit-reveal; листья и узлы Меркла хешируются по-разному
[ ] хеш не используется как «сокрытие» малого пространства значений; KDF для паролей
Ключи [ ] генерация CSPRNG, диапазон 1..n−1 проверяется; в проде libsecp256k1 / noble
[ ] ключ не попадает в логи, дампы окружения, sentry, git
Подписи [ ] результат ecrecover проверяется на address(0); s ≤ n/2 (EIP-2)
[ ] nonce подписанта инкрементируется при использовании
[ ] в подписанных данных есть chainId, адрес контракта, deadline (EIP-712)
[ ] получатель зафиксирован в структуре, а не берётся из аргумента
[ ] пользователь видит осмысленный текст в кошельке, а не hex
Мини-итог
- Хеш даёт целостность и компактный идентификатор — и ничего больше: он не шифрует, не скрывает и не доказывает авторство.
- Keccak-256 в Ethereum отличается от стандарта SHA3-256 одним байтом паддинга; перепутать их — потерянный день отладки. Приватный ключ — число
d, публичный — точкаd·Gна secp256k1, адрес — последние 20 байт keccak от публичного ключа; каждый шаг необратим. - ECDSA-подпись позволяет восстановить отправителя, поэтому в транзакции нет поля
fromи подделать его нечем. Три исторических провала ECDSA — повтор эфемерногоk(лечится RFC 6979), пластичностьs(EIP-2) иecrecover→address(0)— до сих пор регулярно встречаются в свежем коде. - Подпись без
chainId, адреса контракта,nonceиdeadline— это разрешение навсегда, везде и для кого угодно. Главный практический риск сегодня — не математика, а слепая подпись и утечка ключа; EIP-712 существует именно для того, чтобы пользователь видел, что подписывает.
Источники
- Andreas Antonopoulos, Gavin Wood. Mastering Ethereum, главы Keys and Addresses, Transactions: github.com/ethereumbook/ethereumbook
- Jean-Philippe Aumasson. Serious Cryptography, 2nd ed. — хеш-функции и эллиптические кривые
- SEC 2, параметры secp256k1: secg.org/sec2-v2.pdf; NIST FIPS 202, SHA-3 и Keccak: nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.202.pdf
- RFC 6979, детерминированные подписи DSA и ECDSA: datatracker.ietf.org/doc/html/rfc6979
- EIP-55, EIP-155, EIP-712, EIP-1014: eips.ethereum.org/EIPS/eip-712
- BIP-340 (Schnorr) и BIP-173 (bech32): github.com/bitcoin/bips; Yellow Paper: github.com/ethereum/yellowpaper
- Trail of Bits, «ECDSA: Handle with Care»: blog.trailofbits.com/2020/06/11/ecdsa-handle-with-care/
- OpenZeppelin Contracts, модуль ECDSA: docs.openzeppelin.com/contracts/5.x/api/utils#ECDSA
Что дальше
Мы разобрали, как проверяется подлинность записи. Остался второй вопрос: кто и по какому правилу решает, какие подлинные записи попадут в общий журнал и в каком порядке — и почему сеть незнакомцев вообще приходит к одному ответу. Дальше — Механизмы консенсуса: PoW, PoS, финальность, форки, атака 51%.