Криптографические цифровые подписи являются ключевой частью блокчейна. Они используются для подтверждения права собственности без раскрытия закрытого ключа. В основном используется для подписи транзакций, но также может использоваться для подписи произвольных сообщений. Это в свою очередь открывает различные варианты использования в приложениях.
В Ethereum документации дано следующее определение цифровой подписи:
Цифровая подпись - короткая строка данных, которую пользователь создает для документа с использованием закрытого ключа. Любой у кого есть соответствующий открытый ключ, подпись и документ, может проверить следующее:
Документ был «подписан» владельцем этого закрытого ключа.
Документ не был изменен после того, как он был подписан.
Существует множество криптографических алгоритмов, которые используются для шифрования и которые можно применять для создания цифровой подписи. Например RSA и AES.
Но для создания цифровой подписи уже придуман отдельный криптографический алгоритм под названием DSA (Digital Signature Algorithm). Он основан на использовании пары открытого и закрытого ключа. Подпись создается секретно при помощи закрытого ключа, а проверяется публично открытым ключом. Тем самым закрытый ключ остается никому неизвестным.
Сети ethereum и биткойн используют более продвинутый алгоритм цифровой подписи, который основан на эллиптических кривых. Он называется ECDSA(Elliptic Curve Digital Signature Algorithm)
Важно! ECDSA - это только алгоритм для цифровой подписи. В отличие от RSA и AES, его нельзя использовать для шифрования.
Чтобы еще лучше понять, как это работает, можно посмотреть видео, которое простыми словами расскажет про DSA и чем DSA отличается от ECDSA.
Для децентрализованных приложений я бы выделил два основных сценария использования цифровой подписи:
Доказать протоколу, что для вашего публичного адреса, у вас есть приватный ключ, который вы контролируете (аутентификация)
Проверить, что некоторое действие действительно санкционировано вами
Sign message and verify using 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})
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()
.
Sign message vs sign transaction
До этого мы говорили только про подпись сообщений. Для подписи сообщения мы вычисляем хеш сообщения и при помощи приватного ключа вычисляем цифровую подпись.
Для подписи транзакций все немного сложнее. Транзакции кодируются при помощи 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 и сериализацию данных в целом можно посмотреть в этой прекрасной статье.
Как c подписью работают кошельки?
Подпись {r, s, v} объединяется в одну последовательность байт. Длина последовательности равна 65 байтам:
32 байта для r
32 байта для s
1 байт для v.
Если мы закодируем это как шестнадцатеричную строку, мы получим строку длиной 130 символов (не считая 0x в начале). В таком виде подпись используется большинством кошельков и интерфейсов. Например, полная подпись может выглядеть так:
signature: 0x0f1928d8f26b2d9260929425bdc6ac922f7d787fd73b42afe2548776a0e858016f52826d8ab67e1c84e6e6778fa4769d8aa4f014bf76b3280be77e4e0c447f9b1c
r: 0x0f1928d8f26b2d9260929425bdc6ac922f7d787fd73b42afe2548776a0e85801
s: 0x6f52826d8ab67e1c84e6e6778fa4769d8aa4f014bf76b3280be77e4e0c447f9b
v: 1c(в hex) или 28(в decimal)
Стандартизация по работе с подписями
Personal_sign
Personal_sign - это общее названия для процесса подписи сообщения, который мы описали выше. Повторим алгоритм в общих чертах. Сообщение обычно предварительно хешируется, поэтому его длина может составлять фиксированные 32 байта:
"\x19Ethereum Signed Message:\n32" + Keccak256(message)
Затем этот хеш подписывается. Это прекрасно работает для подтверждения права собственности на что-то.
Однако если пользователь A подписывает сообщение и отправляет его контракту X, пользователь B может скопировать это подписанное сообщение и отправить его контракту Y. Это называется повторной атакой.
Если интересно, что было до появления personal_sign
, можно почитать эту статью.
EIP-191: Signed Data Standard
Этот стандарт является очень простым предложением, призванным решить проблему повторной атаки. Он определяет номер версии и данные, относящиеся к версии. Формат выглядит так:
0x19 <1 byte version> <version specific data> <data to sign>
0x19 в начале предназначено для гарантии того, что подписанные данные никогда не смогут распознаться по схеме RLP. Это значит, что подписанные таким образом данные никогда не смогут быть транзакцией.
Затем идет 1 байт для версии. На данный момент существует три версии стандарта:
Версия | EIP | Описание |
---|---|---|
0x00 | 191 | Адрес валидатора. Данные для подписи могут быть любыми и только валидатор знает как с этим работать |
0x01 | 712 | Данные структурированы |
0x45 | 191 | personal_sign |
Подробнее можно посмотреть стандарт тут.
EIP-712: Ethereum typed structured data hashing and signing
Это стандарт для типизации подписываемых данных. Это позволяет сделать данные подписи более проверяемыми, представляя их в удобочитаемом виде.
EIP-712 определяет новый метод. Он пришел на замену personal_sign и назывался eth_signTypedData. Для этого метода мы должны указать все свойства (например, to, amount и nonce) с их соответствующими типами (например, address, uint256). На скриншотах ниже мы можем видеть разницу в подписываемых данных.
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()
можно посмотреть тут.
Примеры
Проверка на стороне смарт-контрактов
Проверка подписи сообщения
// SPDX-License-Identifier: MIT
pragma solidity 0.8.18;
/**
* @notice Контракт проверяет подписанное приватным ключом произвольное сообщение
* @dev Используется встроенная функция ecrecover()
*/
contract SignatureVerifier {
/// @notice Префикс для обозначения, что эта подпись будет использоваться только внутри сети Ethereum
bytes32 constant public PREFIX = "\x19Ethereum Signed Message:\n32";
/// @notice Проверяет была ли подпись сделана адресом signer
function isValid(address signer, bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (bool) {
return _recover(hash, v, r, s) == signer;
}
/// @notice Восстанавливает публичный адрес приватного ключа, которым была сделана передаваямая подпись
function _recover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) private pure returns (address) {
bytes32 prefixedHash = keccak256(abi.encodePacked(PREFIX, hash));
return ecrecover(prefixedHash, v, r, s);
}
}
Проверка подписи по стандарту EIP-712
// SPDX-License-Identifier: MIT
pragma solidity 0.8.18;
import {ECDSA} from "openzeppelin-contracts/utils/cryptography/ECDSA.sol";
/**
* @notice Контракт проверяет подписанное приватным ключом сообщение c типизированными данными согласно EIP-712.
* @dev Используется библиотека от OpenZeppelin ECDSA
*/
contract EIP712 {
bytes32 public constant IS_VALID_TYPEHASH = keccak256("isValid(uint256 nonce)");
/// @notice Счетчик проверки подписи. Позволяет быть уверенным, что одна и таже подпись не бует использована дважды
uint256 public signatureNonce;
error SignatureIsInvalid();
/// @notice 32-байтовый разделитель домена. Используется для определения свойств конкретного приложения.
/// Другими словами подпись может использоваться только для этого приложения
function DOMAIN_SEPARATOR() public view returns (bytes32) {
return keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("EIP712"),
keccak256("1"),
block.chainid,
address(this)
)
);
}
/// @notice hashStruct. Используется для определения типизированных данных подписи
function _getDigest(bytes32 typeHash) private view returns (bytes32) {
return keccak256(
abi.encodePacked(
"\x19\x01", // Согласно EIP-191. Фиксированное значение версии. Определяет "Structured data" EIP-712
DOMAIN_SEPARATOR(),
keccak256(
abi.encode(
typeHash,
signatureNonce + 1
)
)
)
);
}
/**
* @notice Проверяет была ли подпись сделана адресом signer
* @param signer Публичный адрес для проверки, подписавший сообщение
* @param signature Проверяемая подпись (abi.encoded(r, s, v))
*/
function isValid(address signer, bytes memory signature) public view returns (bool) {
bytes32 digest = _getDigest(IS_VALID_TYPEHASH);
address recoveredSigner = ECDSA.recover(digest, signature);
return signer == recoveredSigner;
}
function useSignature(address signer, bytes memory signature) external {
if (!isValid(signer, signature)) {
revert SignatureIsInvalid();
}
signatureNonce += 1;
}
}
Пример, взятый с Solidity by Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/* Signature Verification
How to Sign and Verify
# Signing
1. Create message to sign
2. Hash the message
3. Sign the hash (off chain, keep your private key secret)
# Verify
1. Recreate hash from the original message
2. Recover signer from signature and hash
3. Compare recovered signer to claimed signer
*/
contract VerifySignature {
/* 1. Unlock MetaMask account
ethereum.enable()
*/
/* 2. Get message hash to sign
getMessageHash(
0x14723A09ACff6D2A60DcdF7aA4AFf308FDDC160C,
123,
"coffee and donuts",
1
)
hash = "0xcf36ac4f97dc10d91fc2cbb20d718e94a8cbfe0f82eaedc6a4aa38946fb797cd"
*/
function getMessageHash(
address _to,
uint256 _amount,
string memory _message,
uint256 _nonce
) public pure returns (bytes32) {
return keccak256(abi.encodePacked(_to, _amount, _message, _nonce));
}
/* 3. Sign message hash
# using browser
account = "copy paste account of signer here"
ethereum.request({ method: "personal_sign", params: [account, hash]}).then(console.log)
# using web3
web3.personal.sign(hash, web3.eth.defaultAccount, console.log)
Signature will be different for different accounts
0x993dab3dd91f5c6dc28e17439be475478f5635c92a56e17e82349d3fb2f166196f466c0b4e0c146f285204f0dcb13e5ae67bc33f4b888ec32dfe0a063e8f3f781b
*/
function getEthSignedMessageHash(bytes32 _messageHash)
public
pure
returns (bytes32)
{
/*
Signature is produced by signing a keccak256 hash with the following format:
"\x19Ethereum Signed Message\n" + len(msg) + msg
*/
return keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", _messageHash)
);
}
/* 4. Verify signature
signer = 0xB273216C05A8c0D4F0a4Dd0d7Bae1D2EfFE636dd
to = 0x14723A09ACff6D2A60DcdF7aA4AFf308FDDC160C
amount = 123
message = "coffee and donuts"
nonce = 1
signature =
0x993dab3dd91f5c6dc28e17439be475478f5635c92a56e17e82349d3fb2f166196f466c0b4e0c146f285204f0dcb13e5ae67bc33f4b888ec32dfe0a063e8f3f781b
*/
function verify(
address _signer,
address _to,
uint256 _amount,
string memory _message,
uint256 _nonce,
bytes memory signature
) public pure returns (bool) {
bytes32 messageHash = getMessageHash(_to, _amount, _message, _nonce);
bytes32 ethSignedMessageHash = getEthSignedMessageHash(messageHash);
return recoverSigner(ethSignedMessageHash, signature) == _signer;
}
function recoverSigner(
bytes32 _ethSignedMessageHash,
bytes memory _signature
) public pure returns (address) {
(bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);
return ecrecover(_ethSignedMessageHash, v, r, s);
}
function splitSignature(bytes memory sig)
public
pure
returns (bytes32 r, bytes32 s, uint8 v)
{
require(sig.length == 65, "invalid signature length");
assembly {
/*
First 32 bytes stores the length of the signature
add(sig, 32) = pointer of sig + 32
effectively, skips first 32 bytes of signature
mload(p) loads next 32 bytes starting at the memory address p into memory
*/
// first 32 bytes, after the length prefix
r := mload(add(sig, 32))
// second 32 bytes
s := mload(add(sig, 64))
// final byte (first byte of the next 32 bytes)
v := byte(0, mload(add(sig, 96)))
}
// implicitly return (r, s, v)
}
}
Примеры из реальной жизни
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
Links
Две первые статьи крутые. Простым языком они объяснят базовые понятия криптографических подписей.
Контракт ECDSA для верификации подписей
Математические и криптографические функции. Solidity docs. Можно посмотреть описание
ecrecover()
,keccak256()
и т.д.