Автор: Павел Найданов 🕵️♂️
Криптографические цифровые подписи являются ключевой частью блокчейна. Они используются для подтверждения права собственности на адрес без раскрытия его закрытого ключа. Это в основном используется для подписи транзакций, но также может использоваться для подписи произвольных сообщений. Это в свою очередь открывает различные вариации для использования в своих DApp.
В Ethereum документации дано следующее определение цифровой подписи:
Цифровая подпись - короткая строка данных, которую пользователь создает для документа с использованием закрытого ключа. Любой у кого есть соответствующий открытый ключ, подпись и документ, может проверить:
- Документ был «подписан» владельцем этого закрытого ключа.
- Документ не был изменен после того, как он был подписан.
Подпись можно использовать для:
- Доказательства наличия у вас приватного ключа для некоего публичного адреса (аутентификация)
- Убедиться, что сообщение (например, письмо из электронной почты) не было подделано
Подпись основана на математических формулах. Например для подтверждения письма из электронной почты мы берем само сообщение письма, закрытый ключ и прогоняем все это через математический алгоритм. На выходе получаем цифровую подпись. После этого можно использовать другую математическую формулу. Она позволит интерпретировать цифровую подпись так, чтобы проверить закрытый ключ не раскрывая его.
Существует множество криптографических алгоритмов, которые используются для шифрования и которые можно применять для создания цифровой подписи. Например RSA и AES.
Но для содания цифровой подписи уже придуман отдельный криптографический алгоритм под названием DSA(Digital Signature Algorithm). Он основан на использование пары открытого и закрытого ключа. Подпись создается секретно при помощи закрытого ключа, а проверяется публично открытым ключом. Тем самым закрытый ключ остается никому неизвестным.
Сети ethereum и биткойн используют более продвинутый алгоритм цифровой подписи, который основан на эллиптических кривых. Он называется ECDSA(Elliptic Curve Digital Signature Algorithm)
Важно! ECDSA - это только алгоритм для цифровой подписи. В отличие от RSA и AES, его нельзя использовать для шифрования.
Чтобы еще лучше понять, как это работает, можно посмотреть видео, которое простыми словами расскажет про DSA и чем DSA отличается от ECDSA.
Подписи ECDSA состоят из двух чисел (целых): r и s. Ethereum также использует дополнительную v переменную (идентификатор восстановления). Такая подпись может быть обозначена как {r, s, v}.
Чтобы создать подпись, нужно подписать сообщение закрытым ключом. Алгоритм выглядит следующим образом:
- Вычисляется хеш сообщения. В Ethereum хэш сообщения обычно вычисляется с помощью
Keccak256
. В начало сообщения всегда добавляется\x19Ethereum Signed Message:\n32"
. Это гарантирует, что подпись не может быть использована за пределами Ethereum.Keccak256("\x19Ethereum Signed Message:\n32" + Keccak256(message))
- Создается безопасное случайное значение. Назовем его secret. Использование этого случайного значения позволяет каждый раз получать разную подпись. Когда эта переменная не секретна или может быть вычислена, то вместе с этим можно вычислить и закрытый ключ. Для нас это совсем небезопасно.
- Вычисляется точка (x, y) на эллиптической кривой путем умножения secret на константу G эллиптической кривой. Помним, что алгоритм ECDSA - это история про эллиптические кривые.
- Рассчитывается r и s по специальным формулам на основе точки (x, y) на эллиптической кривой. Погружаться в расчеты не будем, здесь нужны глубокие знания в математике. Если r или s равны нулю, то возвращаемся на шаг 2.
Важно! Повторяем еще раз! Так как в получение подписи мы используем случайный secret, подпись всегда будет разная. Когда secret не secret(не случайный или публично известный), появляется возможность вычислить закрытый ключ на основе двух полученных подписей от одного владельца приватного ключа. Однако существует стандарт детерминированных подписей DSA. Согласно стандарту можно подобрать безопасный secret и всегда использовать только его для подписи всех своих сообщений. С таким secret будет невозможно подобрать закрытый ключ.
V является последним байтом подписи и имеет значение 27( 0x1b) или 28( 0x1c). Этот идентификатор очень важен. Чтобы понять важность, посмотри на формулы вычисления значения r.
r = x₁ mod n
Как ты заметил, r вычисляется только по значению x на горизонтальной оси. Значение по вертикальной оси y не используется. Таким образом, если ты посмотришь на график эллиптической кривой, то ты поймешь, что по одному значению x можно вычислить две точки r.
График конечно же описывает полный процесс вычисления точки r. Но нам сейчас это не так важно. Запоминаем, что в r хранится информация о точке только по оси x, а так как график изогнутый, для такого x найдется два разных по знаку значения y.
Теперь посмотри на формулу вычисления s. И обрати внимание, что для вычисления s используется значение r, которых у нас может быть два, как ты помнишь.
s = k⁻¹(e + rdₐ) mod n
В результате может получится совершенно два разных открытых ключа (то есть, адреса), которые можно восстановить. И вот тут в дело вступает параметр v, который указывает, какое из двух возможных значений r нужно использовать.
Важно! Этот параметр необходим при восстановление публичного адреса из цифровой подписи. В Solidity для этого используется встроенная функция ecrecover()
.
До этого мы говорили только про подпись сообщений. Для подписи сообщения мы вычисляем хеш сообщения и при помощи приватного ключа вычисляем цифровую подпись.
Для подписи транзакций все немного сложнее. Транзакции кодируются при помощи RLP. Кодирование включает в себя все параметры транзакции(nonce, gas price, gas limit, to, value, data) и подпись(v, r, s).
Мы можем закодировать подписанную транзакцию следующим образом:
- Кодировать параметры транзакции:
RLP(nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0).
- Получите хэш Keccak256 неподписанной транзакции в кодировке RLP.
- Подпишите хэш закрытым ключом, используя алгоритм ECDSA.
- Кодировать подписанную транзакцию.
RLP(nonce, gasPrice, gasLimit, to, value, data, v, r, s).
Расшифровав данные транзакции, закодированные с помощью RLP, можно снова получить необработанные параметры транзакции и подпись.
Важно! Это используется внутри сети Ethereum для обмена данными между узлами. Это позволяет снизить затраты на эксплуатацию узла и требования к хранилищу, а также увеличивает пропускную способность сети за счет эфективного использования памяти.
Подробнее про RLP и сериализацию данных в целом можно посмотреть в этой прекрасной статье.
Подпись {r, s, v} объединяется в одну последовательность байт. Длина последовательности равна 65 байтам:
- 32 байта для r
- 32 байта для s
- 1 байт для v.
Если мы закодируем это как шестнадцатеричную строку, мы получим строку длиной 130 символов(не считая 0x в начале). В таком виде подпись используется большинством кошельков и интерфейсов. Например, полная подпись может выглядеть так:
sig: 0x0f1928d8f26b2d9260929425bdc6ac922f7d787fd73b42afe2548776a0e858016f52826d8ab67e1c84e6e6778fa4769d8aa4f014bf76b3280be77e4e0c447f9b1c
r: 0x0f1928d8f26b2d9260929425bdc6ac922f7d787fd73b42afe2548776a0e85801
s: 0x6f52826d8ab67e1c84e6e6778fa4769d8aa4f014bf76b3280be77e4e0c447f9b
v: 1c(в hex) или 28(в decimal)
Personal_sign это общее названия для процесса подписания сообщения, который мы описали выше. Повторим алгоритм в общих чертах. Сообщение обычно предварительно хэшируется, поэтому его длина может составлять фиксированные 32 байта:
"\x19Ethereum Signed Message:\n32" + Keccak256(message)
Затем этот хеш подписывается. Это прекрасно работает для подтверждения права собственности на что-то.
Однако если пользователь A подписывает сообщение и отправляет его контракту X, пользователь B может скопировать это подписанное сообщение и отправить его контракту Y. Это называется повторной атакой.
Если интересно, что было до появления personal_sign
, можно почитать эту статью.
Этот стандарт является очень простым предложением, призванным решить проблему повторной атаки. Он определяет номер версии и данные, относящиеся к версии. Формат выглядит так:
0x19 <1 byte version> <version specific data> <data to sign>
0x19 в начале предназначено для гарантии того, что подписанные данные никогда не смогут распознаться по схеме RLP. Это значит, что подписанные таким образом данные никогда не смогут быть транзакцией.
Затем идет 1 байт для версии. На данный момент существует три версии стандарта:
Версия | EIP | Описание |
---|---|---|
0x00 | 191 | Адрес валидатора. Данные для подписи могут быть любыми и только валидатор знает как с этим работать |
0x01 | 712 | Данные структурированы |
0x45 | 191 | personal_sign |
Подробнее можно посмотреть стандарт тут.
Это стандарт для типизации подписываемых данных. Это позволяет сделать данные подписи более проверяемыми, представляя их в удобочитаемом виде.
EIP-712 определяет новый метод. Он пришел на замену personal_sign и назывался eth_signTypedData(последняя версия eth_signTypedData_v4). Для этого метода мы должны указать все свойства (например, to, amount и nonce) с их соответствующими типами (например, address, uint256 и uint256). На скриншотах ниже мы можем видеть разницу в подписываемых данных.
// Пример personal-sign в кошельке Metamask
// Пример signTypedData в кошельке Metamask
Metamask приготовил хорошее demo. Тут можно поэкспериментировать и посмотреть разницу между подписями.
Дополнительно, согласно стандарту, необходимо указать базовую информацию о приложении, называемую domain.
Domain содержит следующую информацию:
string name
Имя приложения или протоколаstring version
Версия используемой подписи. Данные подписи можно менять и версионировать.uint256 chainId
Идентификатор сети.address verifyingContract
Адрес контракта, который будет верифицировать подписьbytes32 salt
Дополнительное поле соль. Можно использовать для разграничения domain.
Добавление domain решает проблему потенциальной повторной атаки.
В Solidity есть встроенная функция под названием ecrecover()
. На самом деле она является предварительно скомпилированным контрактом по адресу 0x1. Использование этой функции помогает восстановить публичный адрес закрытого ключа, которым было подписано сообщение.
Однако есть подводные камни в использовании ecrecover()
. Согласно EIP-2, в сети ethereum по-прежнему допускается некоторая гибкость в подписи для ecrecover()
. Библиотека ECDSA от openZeppelin позволяет убрать эту возможность и сделать подпись уникальной. На безопасную реализацию ecrecover()
можно посмотреть тут.
- Простой пример проверки подписи сообщения на контракте. На базе Foundry
- Пример работы с подписью по стандарту EIP-712. С использованием библиотеки ECDSA от OpenZeppelin на базе Foundry
- Creating and verifying signatures. Solidity docs
- Пример Solidity by Example
- ERC-2612: Permit Extension for EIP-20 Signed Approvals.. Этот стандарт на базе EIP-712. Хорошая статья, которая дает пояснение к стандарту.
- UniswapV2ERC20.sol контракт расширяет контракт
UniswapV2Pair.sol
и позволяет работать с подписями в своих контрактах периферии. На контракте UniswapV2Router01.sol можно вызывать функциюremoveLiquidityWithPermit()
. - Permit2 от Uniswap. Код можно найти тут. Идея в том, чтобы permit был доступен для токена ERC-20 не зависимо от того, поддерживает ли токен ERC-2612.
- Open GSN использует проверку подписи в своем контракте Forwarder.sol
- Ethers js. Sign message
- Metamask. Signing data
- Open Ethereum. API
- Пример из EIP-712
Две первые статьи крутые. Простым языком они объяснят базовые понятия криптографических подписей.
- The Magic of Digital Signatures on Ethereum
- Intro to Cryptography and Signatures in Ethereum
- EIP-191
- EIP-712
- Контракт ECDSA для верификации подписей
- Математические и криптографические функции. Solidity docs. Можно посмотреть описание
ecrecover()
,keccak256()
и т.д. - Testing EIP-712 Signatures