Добрый день! Меня зовут Кирилл Зиборов, я представляю отдел безопасности распределенных систем Positive Technologies. В этой статье я продолжу рассказывать о том, как мы используем инструменты формальной верификации для предотвращения уязвимостей в различных компонентах блокчейна. Ранее мы верифицировали смарт-контракты дедуктивным методом. В этот раз речь пойдет о протоколах консенсуса — механизмах принятия узлами новых транзакций в цепочку, а именно об алгоритме Istanbul Byzantine Fault Tolerant и в целом о том, как можно гарантировать корректность подобных алгоритмов с помощью метода проверки моделей. 

Протокол консенсуса

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

Существует множество разновидностей протоколов консенсуса, разработанных для удовлетворения различных требований к безопасности, производительности и масштабируемости сети. Наиболее известные из них — Proof of Work (PoW), используемый в Bitcoin, где участники предоставляют вычислительные мощности для решения сложных криптографических задач для подтверждения транзакций, и Proof of Stake (PoS), используемый сейчас в Ethereum (ранее там также использовался PoW), где право создания нового блока выдается участнику с наибольшим количеством нативных токенов. 

Протоколы консенсуса можно разделить на две группы по типу ошибок в сети, к которым они устойчивы. Невизантийские ошибки возникают, когда узлы выходят из строя из-за аппаратных сбоев или сетевых проблем, но не ведут себя злонамеренно. Византийские ошибки, с другой стороны, предполагают наличие вредоносных узлов, которые могут функционировать произвольно или умышленно вредить сети, передавая противоречивую или ложную информацию. Лесли Лэмпорт и Роберт Шостак доказали, что для достижения консенсуса с N византийских узлов распределенная система должна состоять как минимум из 3N + 1 узлов и реализовывать специальный алгоритм консенсуса, устойчивый к византийским ошибкам (Byzantine Fault Tolerant, BFT).

Роль протоколов консенсуса в блокчейнах критически важна для поддержания безопасности и децентрализации сети. Выбор неэффективного протокола консенсуса может привести к сетевым разделениям, при которых разные узлы будут хранить разные цепочки транзакций, или к полной остановке сети. Существует множество примеров атак на протоколы консенсуса. Самая известная из них — атака 51% на консенсусы PoW, позволяющая злоумышленнику с достаточной вычислительной мощностью контролировать сеть и совершать двойные траты. 

Как обезопасить блокчейн от ошибок в консенсусе

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

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

Модели протоколов консенсуса удобно описывать на основе псевдокода, а выразительности языков средств проверки модели обычно достаточно для построения таких моделей. Однако дедуктивный метод тоже может быть использован для верификации протоколов консенсуса. Один из успешных кейсов — это верификация протокола консенсуса Raft во фреймворке Verdi, реализованного в Coq. 

TLA+

В этой статье я остановлюсь на методе проверки моделей и буду использовать язык TLA+ вместе с инструментом проверки модели TLC. TLA+ — это язык для написания моделей распределенных систем и параллельных алгоритмов. Он основан на теории множеств, логике первого порядка и темпоральной логике действий (TLA, temporal logic of actions). Программы, написанные на TLA+, не выполняются компьютером, как программы на обычном языке программирования: они запускаются через средство проверки моделей. Средство проверки моделей (model checker) — это программа, которая исследует все возможные варианты выполнения системы. Пользователь задает поведение и свойство, а model checker проверяет, выполнены ли они для данной модели или нет. В случае если свойство нарушается, средство предоставит контрпример.

Модель в TLA+ — это набор состояний и переходов между этими состояниями, которые задаются в терминах предикатов и действий. Модель описывает поведение системы как последовательность состояний, удовлетворяющих логическим условиям и изменяющихся в результате действий. Для того чтобы написать модель на TLA+, необходимо задать начальное состояние и систему переходов. Проверить модель на выполнение свойств можно, специфицировав эти свойства в виде предикатов и запустив model checker.

С помощью TLA+ было построено и проверено множество моделей различных протоколов консенсуса: Paxos, Raft, Tendermint, HotStuff и другие.

Алгоритм IBFT

Алгоритм Practical Byzantine Fault Tolerant (pBFT) был первым алгоритмом, обеспечивающим достижение консенсуса в частично синхронных сетях с византийскими ошибками, где безопасность не зависит от временных предположений. Эта работа вдохновила множество алгоритмов, улучшающих и дополняющих pBFT, спроектированных в рамках одной и той же частично синхронной модели, где сообщения доставляются в течение ограниченного времени.

Рис 1. Схема работы pBFT
Рис 1. Схема работы pBFT

Алгоритм IBFT (Istanbul Byzantine Fault Tolerant) также вдохновлен pBFT и был предложен в рамках EIP650 (Ethereum Improvement Proposals). Создание алгоритма было мотивировано тем, что pBFT в оригинальном виде не подходит для использования в блокчейн-сетях. Это связано попросту с тем, что он создавался в далеком 1999 году, за 10 лет до появления первого блокчейна Bitcoin. В pBFT, как видно из его схемы, подразумевается существование некоторого «клиента», который отправляет запросы и ждет результатов. Однако в контексте блокчейна мы можем рассматривать всех валидаторов как «клиентов». Кроме того, чтобы цепочка прогрессировала, в каждом раунде должен постоянно выбираться участник, который будет создавать блок-предложение для консенсуса. Также результат работы оригинального pBFT — это набор операций чтения и записи в файловой системе, а от алгоритма консенсуса в блокчейне мы ожидаем выбор нового блока для добавления в цепочку.

IBFT разработан для использования в блокчейн-сетях с разрешениями (permissioned blockchains), где участники известны и идентифицированы. Он был первоначально представлен как часть платформы Quorum, разработанной компанией J.P. Morgan. Однако рассматриваемый алгоритм прошел долгий путь через множество доработок перед полноценным внедрением в Quorum.

Первая версия IBFT не всегда работала корректно: два честных узла могли выбрать различные значения. В обновленном варианте эти проблемы были решены, но появились проблемы с живучестью протокола: некоторые выполнения могли привести к взаимному ожиданию и остановке (deadlock). В ответ на эти проблемы было предложено два решения. Первое решение — избавиться от механизма блокировки узлов и добавить алгоритм смены раундов, похожий на тот, что используется в pBFT. Второе решение было представлено как раз в рамках работы над Quorum, и именно об этой версии алгоритма пойдет речь далее.

https://user-images.githubusercontent.com/1516485/41951806-4824998c-79ff-11e8-99f0-153fca7708a4.jpg

Моделирование протокола консенсуса IBFT на TLA+

Как я уже отметил, модели протоколов консенсуса удобно строить по псевдокоду алгоритма. Именно такой подход я избрал для IBFT, взяв псевдокод из последней публикации Quorum об этом алгоритме

Каждый экземпляр алгоритма проходит по раундам. В каждом раунде один из узлов ведет себя как лидер, чтобы предложить остальным узлам некоторое значение для принятия. Специфицируем функцию выбора лидера на TLA+ следующим образом: Leader == [v \in 0..MaxView |-> v % N], где MaxView — номер последнего раунда, а N — общее число узлов.

Как уже было сказано выше, модель в TLA+ — это набор состояний и переходов между этими состояниями. Состояние программы — это набор значений ее переменных. Для объявления переменных в TLA+ используется ключевое слово VARIABLES.

VARIABLES     
  messages,       \* множество сообщений, отправленных узлами  
  RCmessages      \* множество всех сообщений о смене раунда    
  processState,   \* состояние узлов IBFT  
  decision        \* дополнительная – хранит принятое значение

В IBFT есть четыре типа сообщений, которые могут отправлять узлы: PRE-PREPARE, PREPARE, COMMIT и ROUND-CHANGE. Первые три имеют вид <message-type, λ, r, value>, где λ — номер экземпляра алгоритма, r — текущий раунд, а value — предлагаемое значение; множество всех таких сообщений будем хранить в переменной messages. Сообщения типа ROUND-CHANGE предназначены для случая смены раунда и имеют вид <ROUND-CHANGE, λ, r, pr, pv>, где, помимо номеров экземпляра и раунда, передаются переменные: pr (prepared round) — номер раунда, в котором узел получил кворум сообщений PREPARE, и pv (prepared value) — величина, полученная в этих сообщениях.

В рассматриваемой статье в алгоритме IBFT есть пять переменных состояния: номер экземпляра, отображения из номера процесса в номере текущего раунда, pr, pv, а также исходное предложенное значение. Стоит упомянуть, что я строил модель отдельного экземпляра алгоритма, поэтому номера экземпляров везде будут опущены.

Опишем формально начальное состояние модели протокола. Все узлы находятся в первом раунде, а лидер отправляет сообщение типа PRE-PREPARE с некоторым предлагаемым значением v.

Init == 
  /\ \E v \in Values:
    /\ messages = {[type |-> "PRE-PREPARE", view_num |-> 1,          
                    cmd |-> v, from |-> Leader[1]]}
    /\ processState = [p \in Corr |->
                        [view_num |-> 1,
                         inputValue |-> v,
                         pr |-> NilView,
                         pv |-> NilValue]]
    /\ decision = [p \in Corr |-> NilValue]
    /\ RCmessages = {}

Рассмотрим работу алгоритма в «хорошем» случае, когда лидер не византийский и в сети не происходит сбоев. Сначала каждый честный узел p ждет сообщения PRE-PREPARE от лидера и проверяют его на актуальность предлагаемого значения с помощью предиката JustifyPrePrepare. Если такое сообщение получено, честный узел должен отправить по сети сообщение типа PREPARE.

UponPrePrepared(p) == 
  /\ \E msg \in messages : 
    /\ msg.from = Leader[processState[p].view_num]
    /\ msg.type = "PRE-PREPARE"
    /\ msg.view_num = processState[p].view_num
    /\ JustifyPrePrepare (msg)
    /\ LET prepare_msg == [type |-> "PREPARE", 
                           view_num |-> processState[p].view_num,
                           cmd |-> msg.cmd, from |-> p]
       IN /\ prepare_msg \notin messages
          /\ sendMsg({prepare_msg})
  /\ UNCHANGED <<processState, decision, RCmessages>>

При получении кворума (2F + 1) сообщений типа PREPARE в текущем раунде r c одинаковым значением v, честный узел обновляет значения pr и pv на r и v и отправляет сообщение типа COMMIT с принимаемым значением.

UponPrepared(p) ==
  /\ \E v \in Values:
    /\ Cardinality(PrepareMessages(p, v)) >= QUORUM
    /\ LET commit_msg == [type |-> "COMMIT", 
                          view_num |-> processState[p].view_num,
                          cmd |-> v, from |-> p]
       IN /\ commit_msg \notin messages
          /\ processState' = [processState EXCEPT ![p].pr = processState[p].view_num,
                                                  ![p].pv = v]
          /\ sendMsg({commit_msg}) 
  /\ UNCHANGED <<decision, RCmessages>>

Наконец, при получении кворума сообщений типа COMMIT с одинаковым значением раунда, честный узел принимает значение, обновляя свое значение decision. В оригинальном описании предполагается наличие внешней функции DECIDE, которую вызывает узел. Именно ее мы и моделируем с помощью отображения decision.

UponCommit(p) ==
  /\ decision[p] = NilValue
  /\ \E v \in Values:
    /\ Cardinality(CommitMessages(p, v)) >= QUORUM
    /\ decision' = [decision EXCEPT ![p] = v]
  /\ UNCHANGED <<processState, messages, RCmessages>>

Однако IBFT должен быть устойчив к отказам, в том числе и к византийскому поведению. Это предполагает возможное наличие как просто отключившегося от сети лидера, так и его византийское поведение, когда такой лидер может отправлять любые сообщения, в том числе сообщения с различными значениями для различных узлов. Механизм смены раундов как раз позволяет предотвратить некорректную работу алгоритма в таких случаях.

Реализуем этот механизм в нашей модели с помощью двух действий. Действие Timeout может быть выполнено любым узлом и моделирует некорректную работу сети. В этом случае узел переходит в следующий раунд и отправляет сообщение типа ROUND-CHANGE, которое содержит в себе значения pv и pr, а также новый номер раунда. Действие UponQRC отвечает за возврат алгоритма к нормальной работе: лидирующий узел отправляет сообщение типа PRE-PREPARE в случае получения кворума сообщений типа ROUND-CHANGE.

Timeout(p) == 
  /\ processState[p].view_num < MaxView
  /\ LET rcMsg == [type |-> "ROUND-CHANGE", 
                   view_num |-> processState[p].view_num + 1,
                   pv |-> processState[p].pv, pr |-> processState[p].pr, 
                   from |-> p]
     IN /\ rcMsg \notin RCmessages
        /\ sendRC({rcMsg})
  /\ processState' = [processState EXCEPT ![p].view_num = processState[p].view_num + 1]
  /\ UNCHANGED <<decision, messages>>

RCsMessages(p) == {msg \in RCmessages : /\ msg.type = "ROUND-CHANGE" 
                                        /\ Leader[msg.view_num] = p}

UponQRC(p) ==
  /\ Cardinality(RCsMessages(p)) >= QUORUM
  /\ JustifyRoundChange(RCsMessages(p)) 
  /\ LET q == RCsMessages(p)
         v == IF HighestPrepared[q][2] /= NilValue 
              THEN HighestPrepared[q][2] 
              ELSE processState[p].inputValue
         ppMsg == [view_num |-> processState[p].view_num, 
                   type |-> "PRE-PREPARE",
                   cmd |-> v, from |-> p]
     IN /\ ppMsg \notin messages
        /\ sendMsg({ppMsg})
  /\ UNCHANGED <<decision, processState, RCmessages>>

Стоит отметить некоторые важные шаги при моделирования механизма смены раундов. Они связаны с природой выбранного подхода: средство проверки TLC проверяет все возможные состояния модели. В связи с этим нам необходимо ограничивать все константы, в том числе и количество раундов. Также добавлена проверка processState[p].view_num < MaxView, ограничивающая число тайм-аутов и отсутствующая в оригинальном описании алгоритма. Кроме того, необходимо было опустить моделирование времени для избежания проблемы комбинаторного взрыва состояний при проверке модели. В модели я компенсирую это тем, что узел может уйти в тайм-аут в любом состоянии (кроме последнего раунда). 

Предикаты JustifyRoundChange и JustifyPrePrepare реализованы на основе описания протокола и отвечают за проверку безопасности предлагаемого значения при смене раунда и за предотвращение принятия честными узлами значения, предложенного византийским лидером, соответственно.

Таким образом, спецификация системы переходов модели алгоритма выглядит следующим образом:

Steps(p) == IF p \in Corr 
            THEN \/ UponPrePrepared(p) \/ UponPrepared(p) \/ UponCommit(p) 
                 \/ Timeout(p) \/ UponQRC(p)
            ELSE ByzantineBehavior(p)

Next ==
  \/ \E p \in AllProcs: Steps(p)
  \/ UNCHANGED <<processState, messages, RCmessages, decision>>

Действие ByzantineBehaviour моделирует поведение византийских узлов — они могут отправлять сообщения любого содержания при любом состоянии алгоритма.

Теперь можно переходить к проверке построенной модели. В целом протокол корректно решает задачу византийского консенсуса при условии наличия 2f + 1 честных участников и f византийских, если он обладает тремя ключевыми свойствами. Именно эти свойства необходимо проверить при формальной верификации BFT-протокола:

  1. Согласованность: все корректно работающие узлы принимают одинаковое значение.

  2. Корректность: принятое значение было предложено одним из имеющихся узлов.

  3. Завершаемость: все корректно работающие узлы в конце концов достигают определенного значения.

Первые два свойства относятся к классу safety, то есть гарантируют, что система никогда не перейдет в недопустимое состояние. Специфицируем их на TLA+:

Agreement ==
  \A p, q \in Corr:
    (decision[p] /= NilValue /\ decision[q] /= NilValue) => 
    decision[p] = decision[q]

Validity == \A p \in Corr: decision[p] \in ValuesOrNil

Здесь ValuesOrNil — константное множество, которое мы задаем перед проверкой. Оно состоит из возможных принятых значений и NilValue — пустого значения.

Свойство завершаемости, в свою очередь, относится к классу свойств liveness (живучести), гарантирующих, что система рано или поздно достигнет нужного состояния. Такие свойства вычислительно сложнее в проверке, а для их спецификации обычно используются темпоральные операторы. В данном случаем нам необходим темпоральный оператор <> (eventually). 

Done == \A p \in Corr: decision[p] \in Values
Termination == <>Done

При проверке свойств такого типа необходимо учитывать, что в TLA+ по умолчанию любое поведение допускает stuttering, то есть выполнение действия, в котором ничего не происходит и переменные не изменяются. Более того, TLA+ допускает бесконечное количество таких переходов. Это позволяет симулировать работу реальных сетей, которые могут остановиться в произвольный момент времени. В связи с этим для проверки свойств живучести нам необходимо добавить условие честности (fairness condition), то есть предположение о том, что система не останавливается:

  • Weak fairness означает, что если процесс всегда может прогрессировать, то в конечном итоге он будет прогрессировать. 

  • Strong fairness означает, что если процесс всегда может периодически прогрессировать (то есть действия могут выполняться или не выполняться), то в конце концов он добьется прогресса. 

Добавим условие fairness в нашу спецификацию. Weak fairness влечет за собой strong fairness, поэтому добавим это условие. Weak fairness записывается как темпоральная формула WF_vars(Action), где vars — кортеж всех переменных модели.

Spec == Init 
        /\ [][Next]_vars
        /\ \A p \in AllProcs: WF_vars(Steps(p))

Темпоральный оператор [] (always) означает, что Next будет выполняться всегда. Это стандартный вид записи спецификации: мы задали начальное состояние с помощью Init, систему переходов с помощью Next и условие weak fairness для избежания симуляции остановки системы.

Проверяем модель протокола

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

CONSTANT    
  Corr = {1, 2, 3}      \* множество честных узлов
  Byzantine = {0}       \* множество византийских узлов
  MaxView = 2           \* число раундов
  Values = {"a", "b"}   \* возможные значения

Используем консольную версию TLC 2.19. Как уже было сказано выше, TLC анализирует все возможные состояния модели на выполнение заданных свойств; в отличие от других средств (например, Apalache), мы не можем задать глубину проверки. Если свойство не выполняется, инструмент вернет контрпример — состояние или последовательность состояний (для темпорального свойства), не удовлетворяющую заданному свойству.

В результате проверки модели с максимальным числом раундов, равным 2, получаем успешный результат. Инструменту понадобилось около минуты для полной проверки. 

При увеличении значения MaxView до 3 результат оказался не таким быстрым (проверка проводилась с 10 воркерами):

Model checking completed. No error has been found.
The depth of the complete state graph search is 31.
Finished in 18d 19h

Это означает, что дальнейшее увеличение параметров проверки приведет к разрастанию числа состояний и, как следствие, к длительному времени проверки. В первую очередь это связано с вычислительной сложностью проверки темпорального свойства завершаемости. Проверка только свойств корректности и согласованности при 3, 4 и 5 раундах происходит гораздо быстрее.

Заключение

Протокол консенсуса является фундаментом любого блокчейна, обеспечивая согласованность данных в распределенной сети. Я описал процесс формальной верификации последней версии протокола консенсуса IBFT. По моему мнению, формальная верификации должна применяться ко всем протоколам такого типа, используемым в блокчейн-сетях, дабы строго математически обезопасить их от сбоев. 

В этой статье я хотел показать актуальность метода проверки моделей для верификации протоколов консенсуса. Ограничения этого метода не являются определяющими для таких алгоритмов: почти всегда мы можем построить модели, совпадающие с псевдокодом алгоритмов и их неформальным описанием. Главной проблемой становится ограниченность и вычислительная сложность такого метода. Однако, как мы можем наблюдать, даже инструмент TLC позволяет добиться результата за разумное время, а более современные инструменты (Apalache, Quint и др.) могут проверять модель еще быстрее. IBFT продолжает длинный список протоколов, верифицированных методом model checking: от ставших классическими Paxos и Raft до относительно новых Ceph и ChonkyBFT.

Positive Technologies активно изучает и применяет методы формальной верификации в блокчейнах, мы понимаем их значимость и эффективность. Мы также активно исследуем и внедряем другие инструменты и методики, которые позволяют повысить качество и безопасность разрабатываемых программ. Они включают в себя как новейшие разработки в области формальной верификации, так и проверенные временем методики — статический анализ, тестирование и фаззинг. 

Комментарии (0)