Подписи и реплей: повторное использование и nonce
Подпись — это разрешение. Если её можно проиграть второй раз, разрешение использует кто-то другой.
Реплей-атака (replay) — повторное предъявление ранее действительной подписи или сообщения, чтобы выполнить операцию ещё раз или в другом контексте, где она не предназначалась.
Зачем вообще подписи вне сети
Чтобы пользователь не платил газ за каждое разрешение, многие протоколы принимают подписанные офчейн-сообщения: пользователь подписывает «разрешаю списать N токенов» приватным ключом, а контракт проверяет подпись и исполняет. Это удобно (мета-транзакции, permit у токенов), но открывает класс уязвимостей, если подпись не «одноразовая» и не привязана к контексту.
Проблема 1: повторное проигрывание
Если подпись просто «разрешаю перевод 100», то, будучи однажды опубликованной в блокчейне, она видна всем — и тот же запрос можно подать ещё раз, списав ещё 100, и ещё. Подпись валидна, контракт её принимает. Лечение — nonce: уникальный счётчик, который контракт хранит для каждого пользователя и увеличивает после использования. Подпись включает nonce; повторное предъявление со старым nonce отвергается.
// Идея: подпись привязана к одноразовому nonce
mapping(address => uint256) public nonces;
function execMeta(address user, bytes calldata data, uint256 nonce, bytes calldata sig) external {
require(nonce == nonces[user], "bad nonce"); // только текущий
require(_recover(user, data, nonce) == user, "bad sig");
nonces[user] += 1; // подпись «сгорает»
// ... выполнить data ...
}Проблема 2: реплей между контекстами
Подпись, действительная в одном контракте или сети, может оказаться валидной и в другом, если в неё не «вшиты» эти детали. Поэтому стандарт EIP-712 добавляет в подписываемые данные доменный разделитель: имя протокола, версию, chainId и адрес контракта. Так подпись «для контракта A в сети 1» нельзя проиграть в контракте B или в другой сети.
// EIP-712: домен привязывает подпись к месту назначения
DOMAIN = hash(name, version, chainId, verifyingContract)
digest = hash(DOMAIN, hashStruct(message))
// подпись над digest валидна ТОЛЬКО для этого доменаКак работает под капотом
Контракт восстанавливает адрес подписавшего из подписи (через ecrecover) и сверяет с ожидаемым. Безопасность держится на трёх китах: подпись одноразовая (nonce), контекстная (домен EIP-712) и желательно ограничена по времени (deadline). Отдельно проверяют, что восстановленный адрес не нулевой — это признак некорректной подписи.
Частые ошибки
- Подпись без nonce. Её проиграют повторно.
- Без доменного разделителя. Реплей между контрактами/сетями.
- Не проверять нулевой адрес после восстановления подписавшего.
- Без deadline. Старая подпись «всплывёт» в неудобный момент.
Итоги
- Офчейн-подпись = разрешение; без защиты её можно проиграть повторно.
- nonce делает подпись одноразовой: после использования счётчик растёт.
- EIP-712 (доменный разделитель) привязывает подпись к контракту и сети.
- Добавляйте deadline и проверяйте ненулевой восстановленный адрес.