Подписи и реплей: повторное использование и 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 и проверяйте ненулевой восстановленный адрес.
Проверьте себя
1. Что такое реплей-атака на подпись?
AПодбор приватного ключа
BПовторное предъявление действительной подписи, чтобы выполнить операцию снова
CПодмена байт-кода
DПереполнение uint
2. Как nonce защищает от повторного проигрывания подписи?
AШифрует подпись
BДелает подпись одноразовой: после использования счётчик растёт, старый nonce отвергается
CУскоряет проверку
DСнижает газ
3. Зачем подписи доменный разделитель (EIP-712)?
AДля красоты
BЧтобы привязать подпись к конкретному контракту и сети и запретить реплей между контекстами
CЧтобы увеличить размер подписи
DЧтобы скрыть подписавшего