Безопасность Solidity: Nonce и однократное разрешение
Домен подписи связывает намерение с сетью и экземпляром, но не запрещает повтор в том же контексте. Теперь добавим последовательный nonce и выполнение публикации. Успешная операция потребляет разрешение, а полный откат сохраняет прежний nonce, чтобы отказ не сжигал право незаметно.
Создадим отдельный SignedCourseCatalog для EOA-издателя. Базовый CourseCatalog остаётся прежним, а новый экземпляр имеет собственные поля и ABI. Solidity 0.8.30 и OpenZeppelin 5.4.0 используются как закреплённый профиль исходников; зависимости, подписи и проверки сейчас не запускаются.
Выбираем область nonce
Один immutable издатель имеет последовательность nextNonce. Начальное значение ноль, успешное разрешение увеличивает его на единицу. Это простой договор, но он сериализует подписанные публикации разных курсов. Если нужны независимые параллельные разрешения, потребуется другой явно описанный механизм.
Nonce не заменяет ревизию. Он предотвращает повтор успешного разрешения, а ревизия связывает действие с состоянием конкретного курса. Два разрешения одного издателя могут иметь разные nonce, но одинаковую ожидаемую ревизию; после первого изменения второе окажется устаревшим.
Дедлайн тоже сохраняется. Разрешение должно одновременно иметь текущий nonce, подходящую ревизию и допустимый срок. Не выбираем одно удобное условие вместо остальных, потому что каждое закрывает другую причину несоответствия намерения.
Применяем все условия в одной операции
Ниже полный небольшой контракт только для подписанной публикации. Он не имеет деактивации и не подменяет базовый реестр. В качестве EOA-проверки используется ECDSA.recover закреплённой библиотеки; контрактные подписи добавим отдельно в следующем уроке.
pragma solidity 0.8.30;
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SignedCourseCatalog is EIP712 {
struct Course { bytes32 contentHash; bool active; uint256 revision; }
struct PublishPermit {
uint256 courseId; bytes32 contentHash;
uint256 expectedRevision; uint256 nonce; uint256 deadline;
}
mapping(uint256 => Course) public courses;
address public immutable publisher;
uint256 public nextNonce;
bytes32 public constant TYPEHASH = keccak256(
"PublishPermit(uint256 courseId,bytes32 contentHash,uint256 expectedRevision,uint256 nonce,uint256 deadline)"
);
error InvalidPermit();
error InvalidNonce();
error StaleRevision();
error Expired();
error Unauthorized();
event Published(uint256 indexed id, bytes32 hash, uint256 revision, uint256 nonce);
constructor(address publisher_) EIP712("ProfessorWebSignedCatalog", "1") {
require(publisher_ != address(0), "publisher required");
publisher = publisher_;
}
function publishSigned(PublishPermit calldata permit, bytes calldata signature)
external
{
if (permit.courseId == 0 || permit.contentHash == bytes32(0)) revert InvalidPermit();
if (block.timestamp > permit.deadline) revert Expired();
if (permit.nonce != nextNonce) revert InvalidNonce();
Course storage course = courses[permit.courseId];
if (course.revision != permit.expectedRevision) revert StaleRevision();
bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
TYPEHASH, permit.courseId, permit.contentHash,
permit.expectedRevision, permit.nonce, permit.deadline
)));
if (ECDSA.recover(digest, signature) != publisher) revert Unauthorized();
nextNonce += 1;
course.contentHash = permit.contentHash;
course.active = true;
course.revision += 1;
emit Published(permit.courseId, permit.contentHash, course.revision, permit.nonce);
}
}
Некорректный формат ECDSA может вызвать ошибку самой библиотеки. Это отказ до сохранения состояния, а не Unauthorized во всех случаях. Интерфейс должен различать ошибки формата, роли и nonce, не приписывая любому revert одинаковую причину.
Разбираем первый успех и повтор
Пусть курс 17 ещё не опубликован, revision=0, nextNonce=0. Издатель разрешил H1 с ожиданием ноль и nonce ноль. При допустимом сроке и правильной подписи операция создаёт активную запись ревизии один, а следующий nonce становится единицей.
Повтор точно тех же полей и подписи получает InvalidNonce. Он не создаёт второе событие публикации и не увеличивает ревизию. Доставитель может отличаться, но это не меняет смысл разрешения: итог первого успешного выполнения соответствует подписанным полям.
Если другой человек первым доставил корректное разрешение, он не получает права изменить отпечаток или курс. В нашей модели нет выплаты msg.sender, поэтому копирование доставки не перенаправляет вознаграждение. Для модели с платой такие поля потребовали бы отдельной связи с разрешением.
Что происходит при отказе
Если подпись неверна, срок истёк или ревизия устарела, успешного перехода нет. Изменения nonce и курса не сохраняются. Даже если после увеличения nonce последующее вычисление откатилось бы, полный revert восстановил бы предыдущее состояние этой транзакции.
Мы не используем внешние мутации до потребления nonce. Позже контрактная проверка подписи добавит внешний staticcall, но он имеет другую семантику и не должен объявляться передачей средств. Любой новый внешний эффект требует повторного анализа порядка состояния.
Истёкшее разрешение не может стать действительным только потому, что nonce ещё свободен. Для нового срока нужна новая подпись новых полей. Сервер не вправе продлить deadline внутри полученного разрешения: digest тогда изменится.
Меняем условие: два намерения с nonce ноль
Издатель подписал два разных выпуска с nonce ноль. Первое успешное выполнение потребляет его, второе отклоняется. Такой результат следует из выбранного договора. Нельзя считать оба разрешения независимыми только потому, что они относятся к разным курсам.
Если нужно отменить ещё не использованное разрешение, у контракта должна появиться отдельная авторизованная операция инвалидирования. Наш листинг её не содержит. Следовательно, не обещаем возможность мгновенного отзыва любой подписи из браузера. До исполнения остаются срок и выбранные условия состояния.
Также не делаем новую подпись поверх прежнего свободного nonce без понимания последствий. Две подписи остаются конкурирующими разрешениями, пока одно не исполнено либо не истёк срок. Кошелёк и интерфейс должны показывать эту связь в редакционном процессе.
Сверяем доставку с намерением
При получении хеша транзакции сохраняем nonce разрешения отдельно от nonce отправляющего аккаунта. Первый принадлежит нашему подписному протоколу, второй — порядку транзакций доставителя. Совпадение чисел случайно и не означает одну последовательность. UI, журнал и сообщение об отказе должны называть нужный вид nonce.
Успешный receipt затем связывается с событием и текущим getter. Из события видно, какое разрешение использовано, но загрузка файла всё равно требует проверки его SHA-256 относительно объявленного contentHash. Правильная подпись не делает внешний текст неизменным без этой проверки.
Предметный смысл отказа
Если получен InvalidNonce, возможны повтор успешной операции или конкурирующее разрешение, которое уже потребило последовательность. Если получен StaleRevision, изменение курса произошло в другом допустимом маршруте. Эти причины не исправляют одинаковым действием «переподписать всё»: сначала восстанавливается история состояния и намерение редактора.
Сервер доставки может ограничить срок хранения подписанного payload, но удаление своей копии не отзывает копии у других участников. Отзыв должен быть выражен в договоре контракта либо наступить по сроку. В нашей модели отдельной отмены нет, и это ограничение входит в паспорт готовности. Подписант видит его до будущего использования, а не узнаёт из спорного повтора.
Разбор результата
Подписанное намерение стало однократной операцией с согласованным nonce, ревизией и сроком. Успех потребляет разрешение, отказ сохраняет состояние, повтор не дублирует публикацию. Последовательность по одному издателю является явным ограничением, а не скрытым удобством реализации.
В следующем уроке рассмотрим ERC-1271 и зависимость контрактной подписи от текущего состояния кошелька. Полное оглавление курса связывает дальнейшие границы безопасности.