Этот шаблон проектирования может революционизировать модель безопасности ваших смарт-контрактов, и хакеры его ненавидят!

Этот шаблон проектирования может революционизировать модель безопасности ваших смарт-контрактов, и хакеры его ненавидят!

8 апреля 2023 г.

Шаблоны проектирования в программном обеспечении существуют уже давно. Многие из них не сильно изменились за прошедшие годы (см. «Банда четырех»), потому что они основаны на фундаментальных строительных блоках самой логики и полезны для большинства типов логических машин. Когда дело доходит до блокчейна, некоторые общие устоявшиеся стандарты разработки невозможны или нецелесообразны (в качестве простого примера возьмем скромную циклическую конструкцию). Некоторые стандартные шаблоны проектирования ООП совершенно хороши и рекомендуются в архитектуре блокчейна (смарт-контракта), другие можно использовать с некоторыми изменениями; третьи вообще нет.

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

Эта модель связана с обеспечением контракта. Это не относится к блокчейну, но особенно применимо к разработке смарт-контрактов, и я думаю, что причины этого очевидны. Это несложно и, вероятно, существует под разными именами, но я назову его шаблоном Security Manager. Этот шаблон и сопровождающие его примеры сосредоточены на разработке Solidity для любой цепочки, совместимой с EVM, но могут быть применены (с соответствующими изменениями) и к другим архитектурам блокчейна. Примеры и рассуждения сосредоточены на безопасности на основе ролей (поскольку это наиболее распространенный вариант использования), но ее можно легко применить и к другим моделям безопасности.

Это первая статья из запланированной серии, состоящей из нескольких частей, в которой я буду писать о вопросах безопасности при разработке блокчейна с разных точек зрения.

Ситуация:

  • В большинстве нетривиальных случаев использования ваше DApp состоит не из одного контракта, а из целой семьи смарт-контрактов. Некоторые (может быть, большинство, может быть, все) предоставляют защищенные методы, которые следует вызывать только из разрешенных учетных записей.

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

Наивная реализация:

  • Каждый контракт в семействе, требующий некоторой защиты, будет индивидуально наследовать от AccessControl OpenZeppelin

Хотя это решит основную потребность в наличии ограничений безопасности для контрактов, которые в них нуждаются, у него есть некоторые недостатки:

Проблемы с наивной реализацией:

Раздувание кода. Если вы не так хорошо знакомы с ончейн-разработкой, знайте, что вы будете добавлять класс AccessControl в каждый из ваших развернутых контрактов; код будет повторно использоваться только в логическом смысле, но не в реальном физическом смысле. Вы умножите объем кода, который необходимо развернуть для каждого контракта, наследуемого от AccessControl; код не является действительно общим в этом смысле. А это подразумевает...

Расходы на развертывание. Затраты на развертывание могут быть нетривиальными, особенно если вы будете развертывать одно и то же семейство контрактов более одного раза (например, в разных цепочках или как разные экземпляры в одной и той же цепочке). Увеличение объема кода может значительно увеличить затраты на развертывание. (Что касается моего примера, зависимости OpenZeppelin могут стать большими и, следовательно, дорогостоящими для развертывания)

Операционные расходы. Это относится к расходам на газ для внесения изменений в контракты, связанных с безопасностью. Сценарий: вы хотите добавить трех участников в роль ADMIN. У вас есть шесть контрактов, реализующих безопасность на основе ролей, и эти три новых участника должны иметь права администратора в каждом из шести контрактов. Это 18 сетевых вызовов, которые вам нужно сделать, тогда как их должно быть всего три (или даже один, если вы хотите разрешить несколько назначений для одного вызова).

Возможны ошибки. Когда вы добавляете или удаляете роли и вам приходится выполнять одни и те же операции с несколькими контрактами, вероятность ошибок выше. Это может привести к тому, что не будет удален адрес угрозы безопасности из одного или нескольких контрактов, хотя это было намерением.

Одни и те же роли безопасности определены несколько раз. Небольшое неудобство, но если несколько ваших контрактов распознают одни и те же роли безопасности, вам придется переопределить каждую из этих ролей в каждом контракте, который в них нуждается; это неудобно, а также увеличивает вероятность ошибок/ошибок.

Нарушает букву R в DRY. Заставляет вас повторяться, так как вы в конечном итоге реализуете похожий или идентичный код для управления безопасностью для каждого из контрактов, которые в нем нуждаются.

Как шаблон решает проблемы:

  • Решает проблему раздувания кода: повторно используемый код логически и физически разделен, поэтому вам не придется развертывать один и тот же код несколько раз.

* Снижает затраты на развертывание, так как вы будете развертывать код управления доступом не несколько раз, а только один раз.

* Снижает эксплуатационные расходы, так как операции по управлению безопасностью (предоставление роли, отзыв роли и т. д.) выполняются в одном месте с минимальным количеством операций. Снижает вероятность потенциальных нарушений безопасности по той же причине.

* Решает проблему множественных определений одних и тех же ролей безопасности; они определяются только в диспетчере безопасности.

* Возвращает R в DRY, удаляя весь избыточный код, определения и объявления, связанные с безопасностью.

Дополнительные преимущества дизайна:

  • Если позволяет ваша ситуация, один и тот же Security Manager можно использовать в нескольких экземплярах всей вашей контрактной сети. Кроме того, если ваша ситуация позволяет, его можно использовать даже в нескольких проектах, и проекты даже не должны быть связаны каким-либо другим образом (хотя вам действительно следует действительно изучить свои варианты использования, чтобы определить, это принесет вам пользу в долгосрочной перспективе).

* Наследование не используется, поэтому вы не запутаете свой граф наследования. Модель множественного наследования Solidity — щекотливая тема, и некоторые люди догматически отвергают идею множественного наследования. Эта модель позволяет избежать всей этой проблемы, поскольку ваши контракты ссылаются на Security Manager, но не расширяют базовый класс.

* Гибкость модульной конструкции состоит в том, что она позволяет централизовать безопасность, не требуя этого от вас. Представьте себе, например, сеть из 5 контрактов (не включая Security Manager), в которой 3 из них используют Security Manager A, а 2 используют Security Manager B. Сеть обслуживают два разных Security Manager (и, предположительно, по уважительной причине). - т.е. свой профиль безопасности для каждой подгруппы контрактов). Это вполне возможно. Позже также можно объединить их под эгидой единого диспетчера безопасности. Можно отказаться от текущего диспетчера безопасности (без обновления каких-либо контрактов) и подключить новый. Если в будущем для каждого контракта потребуется свой индивидуальный Security Manager, это тоже можно сделать; конструкция является модульной, что обеспечивает различные типы гибкости.

Реализация диспетчера безопасности:

Весь код для этого примера находится здесь: просмотреть код на github

Шаг 0: Наивная реализация

просмотреть код на github< /p>

* внедрить один контракт, контролирующий собственную безопасность, с помощью OpenZeppelin AccessControl (безопасность на основе ролей)

Это пример наивной реализации, описанной выше, в которой каждый контракт индивидуально обрабатывает свою собственную безопасность.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

import "../inc/AccessControl.sol";

//note that this contract inherits AccessControl directly
contract Contract is AccessControl {
    uint256 public publicValue = 0;
    uint256 public restrictedValue1 = 0;
    uint256 public restrictedValue2 = 0;

    constructor() {
    }

    function publicMethod(uint256 value) external {
        //implementation
        publicValue = value;
    }

    // restricted method 1
    function setRestrictedValue1(uint256 value) external onlyRole(keccak256("ADMIN_ROLE")) {
        //implementation
        restrictedValue1 = value;
    }

    // restricted method 2
    function setRestrictedValue2(uint256 value) external onlyRole(keccak256("MANAGER_ROLE")) {
        //implementation
        restrictedValue2 = value;
    }
}

Шаг 1. Добавьте диспетчера безопасности

просмотреть код на github

* реализовать контракт SecurityManager * изменить Контракт, чтобы использовать ссылку на SecurityManager вместо наследования AccessControl

Здесь создается контракт SecurityManager (который управляет доступом через AccessControl OpenZeppelin), и Contract изменяется так, чтобы он ссылался на экземпляр SecurityManager. Убедитесь, что SecurityManager обеспечивает необходимый доступ к базовым протоколам безопасности, позволяя вызывающим сторонам запрашивать, отзывать, отказываться и предоставлять роли.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

import "../inc/AccessControl.sol";

//note that this contract now takes on the job of inheriting AccessControl
contract SecurityManager is AccessControl {
    bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE");

    constructor(address admin) {
        _grantRole(ADMIN_ROLE, admin);
    }

    function hasRole(bytes32 role, address account) public view virtual override returns (bool) {
        return super.hasRole(role, account);
    }

    function renounceRole(bytes32 role, address account) public virtual override  {
        super.renounceRole(role, account);
    }

    function revokeRole(bytes32 role, address account) public virtual override  {
        super.revokeRole(role, account);
    }
}

Контракт теперь будет изменен так, чтобы он содержал ссылку на SecurityManager. Обратите внимание, что Contract больше не наследуется от AccessControl.

contract Contract {
    //the security manager 
    SecurityManager public securityManager;

    uint256 public publicValue = 0;
    uint256 public restrictedValue1 = 0;
    uint256 public restrictedValue2 = 0;

    // Security Manager is linked at deployment 
    constructor(SecurityManager _securityManager) {
        securityManager = _securityManager;
    }

    function publicMethod(uint256 value) external {
        //implementation
        publicValue = value;
    }

    // restricted method 1: now uses SecurityManager
    function setRestrictedValue1(uint256 value) external { // this method is for ADMIN role only 
        require (
            securityManager.hasRole(keccak256("ADMIN_ROLE"), msg.sender), 
            "Caller not authorized"
        );

        //implementation
        restrictedValue1 = value;
    }

    // restricted method 2: now uses SecurityManager
    function setRestrictedValue2(uint256 value) external { // this method is for MANAGER role only 
        require (
            securityManager.hasRole(keccak256("MANAGER_ROLE"), msg.sender),
            "Caller not authorized"
        );

        //implementation
        restrictedValue2 = value;
    }
}

Шаг 2. Разделите контракт на два контракта

просмотреть код на github

* разделить Контракт на Контракт1 и Контракт2 * оба контракта продолжат использовать SecurityManager

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

contract Contract1 {
    SecurityManager public securityManager;
    uint256 public publicValue = 0;
    uint256 public restrictedValue1 = 0;

    constructor(SecurityManager _securityManager) {
        securityManager = _securityManager;
    }

    function publicMethod(uint256 value) external {
        //implementation
        publicValue = value;
    }

    // Contract1 has restricted method 1
    function setRestrictedValue1(uint256 value) external { // this method is for ADMIN role only 
        require (
            securityManager.hasRole(keccak256("ADMIN_ROLE"), msg.sender), 
            "Caller not authorized"
        );

        //implementation
        restrictedValue1 = value;
    }
}

contract Contract2 {
    SecurityManager public securityManager;
    uint256 public publicValue = 0;
    uint256 public restrictedValue2 = 0;

    constructor(SecurityManager _securityManager) {
        securityManager = _securityManager;
    }

    function publicMethod(uint256 value) external {
        //implementation
        publicValue = value;
    }

    // Contract2 has restricted method2
    function setRestrictedValue2(uint256 value) external { // this method is for MANAGER role only
        require (
            securityManager.hasRole(keccak256("MANAGER_ROLE"), msg.sender),
            "Caller not authorized"
        );

        //implementation
        restrictedValue2 = value;
    }
}

Шаг 3. Упростите, устранив избыточность

просмотреть код на github

* реализовать класс SecuredContract * изменить Contract1 и Contract2, чтобы они наследовали SecuredContract * Замените вызов 'require' модификатором

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

// this class is new; it generalizes the role of a "secured" contract (one which uses the SecurityManager)
contract SecuredContract {

    //roles 
    bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE");
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");

    //the security manager instance 
    SecurityManager public securityManager;

    //thrown when the onlyRole modifier reverts 
    error UnauthorizedAccess(bytes32 roleId, address addr); 

    //Restricts function calls to callers that have a specified security role only 
    modifier onlyRole(bytes32 role) {
        if (!securityManager.hasRole(role, msg.sender)) {
            revert UnauthorizedAccess(role, msg.sender);
        }
        _;
    }

    //constructor
    constructor(SecurityManager _securityManager) {
        securityManager = _securityManager;
    }
}

contract Contract1 is SecuredContract {
    uint256 public publicValue = 0;
    uint256 public restrictedValue1 = 0;

    constructor(SecurityManager _securityManager) SecuredContract(_securityManager) {
    }

    function publicMethod(uint256 value) external {
        //implementation
        publicValue = value;
    }

    // restricted method 1 is simplified by use of modifier (still controlled by SecurityManager)
    function setRestrictedValue1(uint256 value) external onlyRole(ADMIN_ROLE) {
        //implementation
        restrictedValue1 = value;
    }
}

contract Contract2 is SecuredContract {
    uint256 public publicValue = 0;
    uint256 public restrictedValue2 = 0;

    constructor(SecurityManager _securityManager) SecuredContract(_securityManager) {
    }

    function publicMethod(uint256 value) external {
        //implementation
        publicValue = value;
    }

    // restricted method 1 is simplified by use of modifier (still controlled by SecurityManager)
    function setRestrictedValue2(uint256 value) external onlyRole(MANAGER_ROLE) {
        //implementation
        restrictedValue2 = value;
    }
}

Шаг 4. Последние штрихи: скрытие SecurityManager за интерфейсом

просмотреть код на github

* создать интерфейс ISecurityManager в новом файле * заставить SecurityManager реализовать ISecurityManager * изменить все ссылки на SecurityManager в SecuredContract и Contract1/2 на ISecurityManager

Мы создадим интерфейс с именем ISecurityManager и заставим SeceurityManager реализовать его.

Помимо обычных преимуществ дизайна, связанных с сокрытием реализаций за интерфейсами, для этого есть и реальная практическая причина; и для достижения преимущества вам потребуется хранить ISecurityManager и SecurityManager в разных файлах .sol. Когда вы развертываете новые контракты, которые ссылаются на существующий сетевой SecurityManager, вам не нужно будет развертывать с ним весь код контракта SecurityManager; только интерфейс. Мало того, что нет необходимости повторно развертывать реализацию SecurityManager, это может значительно увеличить ваши затраты на развертывание!

// this generalizes the interface of SecurityManager and hides its implementation
interface ISecurityManager {
    function hasRole(bytes32 role, address account) external view returns (bool);
}

// SecurityManager now is an ISecurityManager as well 
contract SecurityManager is AccessControl, ISecurityManager { 
    .... 

Теперь все, что раньше ссылалось на SecurityManager, теперь может ссылаться на ISecurityManager. Цель этого — уменьшить раздувание кода при развертывании новых контрактов.

contract Contract1 is SecuredContract {
    uint256 public publicValue = 0;
    uint256 public restrictedValue1 = 0;

    // here, and in other places, refer to ISecurityManager instead of SecurityManager directly
    constructor(ISecurityManager _securityManager) SecuredContract(_securityManager) { }
    .... 

Шаг 5. Последние штрихи: предотвращение случайного скручивания

просмотреть код на github

* добавить код в SecurityManager.revokeRole для предотвращения зависания * добавьте код в SecurityManager.renounceRole, чтобы предотвратить зависание

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

Для этого я просто (моя собственная лучшая практика здесь) хотел бы добавить некоторую защиту от этого. Если вызывающий абонент является АДМИНИСТРАТОРОМ, в этом случае вызывающий абонент не может ни отказаться, ни отозвать свою собственную роль администратора. Обратите внимание, что он может отказываться от ролей администраторов других администраторов, но не от своей собственной. Это значительно снижает вероятность того, что вы попадете в ситуацию смертельного исхода.

В SecurityManager.sol:

    // this is added to prevent against accidentally renouncing the admin role of the only remaining admin 
    function renounceRole(bytes32 role, address account) public virtual override  {
        if (role != ADMIN_ROLE) {
            super.renounceRole(role, account);
        }
    }

    // this is added to prevent against accidentally revoking the admin role of the only remaining admin 
    function revokeRole(bytes32 role, address account) public virtual override  {
        if (account != msg.sender || role != ADMIN_ROLE) {
            super.revokeRole(role, account);
        }
    }

Дальнейшие шаги

просмотреть код на github

В приведенном выше коде вы можете видеть, что я добавил некоторые дополнительные тонкости.

* общедоступный метод позволяет администратору перейти на новый SecurityManager (класс SecuredContract, setSecurityManager) * добавлены дополнительные проверки при настройке SecurityManager как в конструкторе, так и в методе setSecurityManager (например, проверка нулевого адреса) * Комментарии * пользовательские ошибки

Некоторые примечания к приведенному выше примеру:

* Это сильно упрощено для ясности * В примерах используется OpenZeppelin AccessControl для обеспечения безопасности на основе ролей. На самом деле этот метод не является специфичным ни для OpenZeppelin, ни для безопасности на основе ролей. Почти любая реализация безопасности должна быть пригодной для использования. * Ранее я упоминал, что одним из дополнительных преимуществ является сокращение использования наследования. На самом деле наследование по-прежнему используется для SecurityManager, но это (а) необязательное и (б) только одиночное наследование. Наследование также используется для SecuredContract -> Contract1 и Contract2, но это необязательно; есть и другие способы сократить повторное использование кода (например, библиотеки и тому подобное). * Изменения ролей безопасности (например, предоставление ролей, отзыв ролей) выполняются непосредственно в SecurityManager. Вот почему ISecurityManager не реализует эти методы (он реализует только методы, необходимые для клиентских контрактов).

Заключение

Это пример шаблона проектирования, который хорошо подходит для разработки контрактов, совместимых с EVM, и разработки смарт-контрактов в целом по причинам, изложенным выше. Целью шаблона является реализация безопасности при одновременном повышении модульности и повторного использования, а также уменьшении раздувания кода.

Смотрите образец в дикой природе здесь: https://bscscan.com/address/0x65aFe9D3cfE457271a78D86638F7834e2d4b11Fd#code

Пожалуйста, проверьте мой github, если вы заинтересованы в обсуждении проекта: https://github.com/jrkosinski/Smart-Contract-Architect-Develeoper


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE