EIP-155: привязываем подпись к своей цепочке
Подпись Ethereum до EIP-155 говорила, кто подписал и что подписал — но никогда не говорила, для какой сети это было. Так что в точности одни и те же подписанные байты были валидны на каждой цепочке, разделяющей ваш адрес, и транзакцию можно было воспроизвести через форк вроде ETH/ETC. EIP-155 вплетает chainId в то, что вы подписываете, так что подпись оказывается заперта на одной цепочке.
- цифровые подписи
- что такое транзакция
Подпись доказывает две вещи: кто подписал и что он подписал. Ранние транзакции Ethereum подписывались над [nonce, gasPrice, gasLimit, to, value, data] — действие, но не сеть, для которой оно предназначалось. Это упущение кажется безобидным, пока не вспомнить, что ваш закрытый ключ выводит один и тот же адрес на каждой Ethereum-подобной цепочке. Так что идеально валидная подписанная транзакция на одной сети — это идеально валидная подписанная транзакция и на другой: скопируйте байты, отправьте их в другом месте, и они выполнятся снова. Это обернулось катастрофой при расколе ETH/ETC в 2016 году, когда две цепочки внезапно стали делить адреса, балансы и nonce. EIP-155 — это идея в одну строку, которая это чинит: поместить цепочку внутрь подписи. Давайте выведем её.
Проблема: подпись, валидная везде
Чтобы отправить транзакцию, вы подписываете хеш её полей. До EIP-155 этими полями были просто [nonce, gasPrice, gasLimit, to, value, data], а подпись (v, r, s) использовала v = 27 или 28 — чисто идентификатор восстановления Дополнительный бит (чётность), хранимый в v, который позволяет проверяющему восстановить открытый ключ подписанта из r и s. До EIP-155 это было просто 27 или 28 — один бит, не несущий больше никакой информации. (recovery id), сообщающий проверяющему, какой из двух возможных открытых ключей был восстановлен. Нигде в подписанных данных нет ничего, что говорило бы: «эта транзакция для мейннета Ethereum». Так что подпись авторизует действие на любой цепочке, которая распознаёт ваш адрес.
Это атака воспроизведения Повторная рассылка валидно подписанной транзакции в другой цепочке (или после форка), где подписант никогда её не предполагал. Поскольку подпись всё ещё валидна, транзакция выполняется снова, перемещая средства, которые пользователь никогда не собирался туда перемещать. (replay attack). После хардфорка DAO у ETH и ETC была идентичная история вплоть до раскола, так что транзакция, сделанная вами в одной цепочке, мгновенно становилась воспроизводимой в другой — опустошая или дублируя переводы без какого-либо дополнительного подписания.
→ Шаг 2: делаем цепочку частью того, что вы подписываете.
Вплетаем chainId в подписанные данные
Дайте каждой сети публичный номер — chainId Уникальное целое число, идентифицирующее цепочку: 1 для мейннета Ethereum, 11155111 для Sepolia, 61 для Ethereum Classic и так далее. Зарегистрировано, чтобы кошельки и узлы соглашались, для какой сети они подписывают. (мейннет — это 1, Ethereum Classic — 61). Теперь, вместо хеширования всего шести полей, хешируем список с добавленным chainId плюс две нулевые заглушки: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]. Именно весь этот список keccak-хешируется и подписывается. Поскольку chainId теперь внутри хеша, подписываемый дайджест отличается на каждой цепочке — подпись, вычисленная для chainId 1, просто не является подписью над дайджестом, который вычислил бы chainId 61.
Два хвостовых нуля — изящный трюк: они делают подписываемую структуру похожей на девятиэлементную транзакцию, чьи слоты (v, r, s) равны (chainId, 0, 0), переиспользуя уже существующую форму кодирования вместо изобретения новой.
→ Шаг 3: протаскиваем chainId внутрь v.
Кодируем chainId в v — с обратной совместимостью
Для нового поля нет места, так что переиспользуем v. Переопределим его как v = chainId·2 + 35 + parity, где parity — это бит восстановления 0/1. На мейннете (chainId 1) это даёт v = 37 или 38; получатель читает v, извлекает из него chainId и точно знает, какой дайджест восстановить перед восстановлением подписанта. И, что критично, любое унаследованное значение ниже этого диапазона: v = 27/28 по-прежнему означает «без защиты, без chainId», так что старые транзакции остаются валидными навсегда — они просто воспроизводимы, по собственному выбору.
- 1 идентификатор сети — 1 для мейннета, 61 для Ethereum Classic
- 2 смещение EIP-155; сохраняет валидность унаследованных v = 27/28
- 3 бит восстановления подписи, 0 или 1
→ Шаг 4: смотрим, как не та цепочка её отклоняет.
Награда: воспроизведение проваливает восстановление подписанта
Теперь проследите за атакующим, копирующим вашу транзакцию с chainId-1 в цепочку с chainId 61. Эта цепочка валидирует, восстанавливая подписанный дайджест со своим собственным chainId, 61 — не 1. Хеш, который она строит, отличается от того, который вы действительно подписали, так что когда она запускает ECDSA-восстановление над вашими (r, s), выскакивающий открытый ключ — не ваш адрес. Транзакция выглядит подписанной каким-то случайным аккаунтом без средств, и она отклоняется. Подпись была криптографически вварена в цепочку 1; она не может путешествовать.