Web3 и блокчейн Криптография Web3: хеши, пары ключей, подписи, адреса
0%

Криптография Web3: хеши, пары ключей, подписи, адреса

Криптография 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 байта.

Адрес: хеш публичного ключа, а не сам ключ

Байтовый путь от приватного ключа к адресу и контрольной сумме EIP-55

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: механика и её острые углы

Анатомия подписи транзакции: поля, RLP, keccak256, дайджест, r, s, v и ecrecover

Подпись дайджеста 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) и ecrecoveraddress(0) — до сих пор регулярно встречаются в свежем коде.
  • Подпись без chainId, адреса контракта, nonce и deadline — это разрешение навсегда, везде и для кого угодно. Главный практический риск сегодня — не математика, а слепая подпись и утечка ключа; EIP-712 существует именно для того, чтобы пользователь видел, что подписывает.

Источники

Что дальше

Мы разобрали, как проверяется подлинность записи. Остался второй вопрос: кто и по какому правилу решает, какие подлинные записи попадут в общий журнал и в каком порядке — и почему сеть незнакомцев вообще приходит к одному ответу. Дальше — Механизмы консенсуса: PoW, PoS, финальность, форки, атака 51%.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов