Перейти к содержанию

Подписи Web3: домен и защита от replay-атаки

Ожидаемая ревизия выражает, что издатель хочет изменить. Теперь рассмотрим разрешение, которое он подписывает вне цепочки, а другой участник позже передаёт контракту. Сама подпись должна быть связана с конкретным действием, сетью и проверяющим экземпляром. Иначе одинаковые поля могли бы получить смысл в другом приложении.

В этом уроке определим только формат и вычисление digest через EIP-712. Новый компонент PublishIntentInspector ничего не публикует и не потребляет nonce. Это позволяет изучить домен отдельно от однократного исполнения. Библиотеки не устанавливаются, подписи и программы сейчас не выполняются.

Отличаем подпись от отправления

Подписанное намерение не является обычной отправленной транзакцией издателя. Его может доставить другой адрес, поэтому msg.sender доставки не обязан совпадать с автором разрешения. Если операция платит вознаграждение доставителю, получатель должен быть явно частью подписанных условий либо определяться безопасной политикой.

Для нашей модели нет вознаграждения и передачи средств. Намерение только объявляет выпуск курса. Подписанные поля содержат id, новый отпечаток, ожидаемую ревизию, nonce и дедлайн. Произвольный JSON без типового договора не считается таким разрешением.

Домен отделяет это намерение от похожего сообщения другого приложения. Используем имя ProfessorWebSignedCatalog, версию 1, текущий chain id и адрес проверяющего контракта. Эти значения относятся к конкретному экземпляру, а не только к красивому названию в модальном окне кошелька.

Фиксируем тип полей

Тип PublishPermit и порядок его полей должны совпадать в контракте и будущем клиенте. Нельзя заменить uint256 на строку «17» в типовом описании, а потом надеяться, что все библиотеки одинаково исправят формат. Для динамических полей тоже существуют отдельные правила, но наш пример использует только фиксированные типы.

В EIP-712 определены типовое хеширование и домен. Важно, что стандарт сам не обеспечивает защиту от повторного применения. Поле nonce будет иметь смысл только тогда, когда приложение проверяет и сохраняет его использование.

pragma solidity 0.8.30;
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract PublishIntentInspector is EIP712 {
    struct PublishPermit {
        uint256 courseId;
        bytes32 contentHash;
        uint256 expectedRevision;
        uint256 nonce;
        uint256 deadline;
    }
    bytes32 public constant TYPEHASH = keccak256(
        "PublishPermit(uint256 courseId,bytes32 contentHash,uint256 expectedRevision,uint256 nonce,uint256 deadline)"
    );

    constructor() EIP712("ProfessorWebSignedCatalog", "1") {}

    function digest(PublishPermit calldata permit)
        external view returns (bytes32)
    {
        bytes32 structHash = keccak256(abi.encode(
            TYPEHASH, permit.courseId, permit.contentHash,
            permit.expectedRevision, permit.nonce, permit.deadline
        ));
        return _hashTypedDataV4(structHash);
    }
}

Здесь выбран закреплённый OpenZeppelin Contracts 5.4.0. В его EIP712.sol домен включает chain id и адрес текущего контекста. Код архивирован для чтения, но зависимость не установлена и компиляция не выполнялась.

Разбираем смысл разных отпечатков

contentHash внутри разрешения — SHA-256 точных байтов манифеста библиотеки. structHash и общий digest EIP-712 вычисляются другим протоколом для подписываемых данных. Их нельзя подставлять друг вместо друга: это разные объекты с разным назначением.

CID IPFS тоже не становится одним из этих digest автоматически. Если потребуется подписать CID, типовой договор должен явно включить его строку либо определённое бинарное представление. В нашей модели разрешение подписывает объявленный отпечаток выпуска, а внешний паспорт связывает его с местом хранения.

Функция digest не объявляет разрешение действительным. Она только вычисляет, какие байты относятся к данным в выбранном домене. Срок, роль подписанта, ревизия и nonce пока не проверяются. Внешний интерфейс должен сохранять это различие при отображении результата.

Меняем условие: другой контракт

Пусть те же поля разрешения передали другому экземпляру инспектора в той же сети. Адрес проверяющего контракта изменится, поэтому домен и digest будут другими. Подпись, предназначенная первому экземпляру, не должна становиться универсальным редакционным правом во втором.

При смене сети с другим chain id возникает аналогичное различие. Однако fork с тем же chain id требует отдельного анализа домена и истории. Нельзя утверждать, что одно поле автоматически решило любые варианты дублирования сети. Для приложения важен конкретный договор и выбранная среда.

Обновление протокольной версии может потребовать смены имени или версии домена. Если новый код придаёт прежним полям другой смысл, сохранение прежнего домена становится риском совместимости. Это обсуждают до миграции, а не после появления спорной подписи.

Что показывает интерфейс подписания

Пользователь должен видеть курс, отпечаток, ожидаемую ревизию и срок. Длинный хеш удобно сокращать на экране, но рядом нужен доступ к полному значению и исходному манифесту. Нельзя показывать одно имя файла, когда подписываемые данные относятся к другому содержимому.

Точно так же интерфейс отображает сеть и проверяющий экземпляр. Подпись в другом домене — другое разрешение, даже если экранная фраза «Опубликовать курс» совпадает. Переключение аккаунта или сети требует повторной подготовки данных, а не тихой замены домена после согласования.

Не просим вставлять приватный ключ на сайт и не приводим фиктивную действительную подпись. В будущем подпись получает выбранный кошелёк по зафиксированному типу. Сейчас изучаем только формат намерения и ожидаемое изменение digest при изменении полей.

Типовой договор не исправляет лишние поля

Если будущий клиент добавил в JSON неподписанное экранное поле «получатель», оно не становится частью разрешения только из-за соседства с типовыми данными. Проверяющий контракт использует ровно определённые поля. Поэтому паспорт действия разделяет подписанные значения и пояснения интерфейса; важные ограничения должны присутствовать в самой схеме, а не только в красивой подписи кнопки.

Повтор в том же домене

Одно и то же сообщение в том же экземпляре даёт тот же digest. Это необходимая воспроизводимость, но она позволяет доставить прежнюю подпись повторно. Одного срока недостаточно: в течение действующего окна повтор всё ещё возможен.

Если два разрешения имеют одинаковый nonce, приложение должно выбрать, какой успешный переход потребляет его, и как обрабатывает отказ. В следующем уроке зададим последовательный nonce и атомарное применение. Пока инспектор намеренно остаётся без мутации и не выдаётся за защищённый подписной реестр.

Мы связали поля намерения с типом и доменом, сохранив разные назначения отпечатков. Следующий шаг — однократное разрешение. Все главы находятся в оглавлении курса.

Оглавление серии