НОВОСТИ Ненормальная криптография, или как я проверял подписи Ed25519 на Solidity

NewsBot
Оффлайн

NewsBot

.
.
Регистрация
21.07.20
Сообщения
40.408
Реакции
1
Репутация
0
Когда пишут о том, как разрабатывать безопасные приложения, один из частых советов звучит так: не пишите криптографию самостоятельно, а используйте какую-нибудь проверенную библиотеку. К сожалению, при разработке блокчейнов этот совет часто не работает: нужные алгоритмы либо не реализованы вовсе, либо существующие реализации не годятся по множеству возможных причин — даже потому, что они . Вот и в этом случае нужно было проверять подписи, использующие — весьма популярный тип подписей, для которого существует множество реализаций, ни одна из которых, однако, нам не подошла. А всё потому, что проверка должна была выполняться из смарт-контракта, работающего на блокчейне Ethereum.


Что такое смарт-контракт
Смарт-контракт — это специальная программа, которая в некотором смысле выполняется «внутри блокчейна». Технически это реализовано так: каждый узел, поддерживающий блокчейн, выполняет код смарт-контракта и проверяет, что результат выполнения, записанный в блокчейне, совпадает с ожидаемым. В результате смарт-контракты обеспечивают намного более высокий уровень безопасности для критических вычислений: в то время как для того, чтобы изменить результат работы обычной программы, достаточно взломать только тот компьютер, на котором она выполняется, для вмешательства в смарт-контракт необходимо взять под контроль бо́льшую часть узлов. Если какой-то один узел выполнит смарт-контракт неправильно, другие узлы не примут его результат. Благодаря механизму голосования, в итоге в блокчейне останется тот результат, на котором сошлось большинство узлов.


В чём же проблема?



В Ethereum смарт-контракты выполняются в специальном окружении — так называемой виртуальной машине Ethereum (Ethereum virtual machine, EVM).

Что такое EVM

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


EVM разработана, чтобы быть простой в реализации, но ценой этого является низкая эффективность. Кроме того, все вычисления на блокчейне повторяются всеми узлами, поэтому стоимость этих вычислений на порядки больше, чем, например, стоимость вычислений на облачном сервере. По этой причине смарт-контракты приходится тщательно оптимизировать. Поскольку EVM существенно отличается от окружения, в котором работает большинство программ, программы, написанные на обычных языках программирования, не работают в EVM. Если бы обычные языки и допускали трансляцию в EVM-байткод, из-за различий архитектуры код практически любой реализации Ed25519 был бы слишком неэффективен — настолько, что ресурсов, выделяемых на обработку одной транзакции, не хватило бы даже, чтобы проверить одну подпись. Вместо них, для EVM разработано несколько специальных языков, и самый популярный из них — .



EVM существенно отличается как от популярных процессорных архитектур, так и от часто используемых абстрактных виртуальных машин, таких как JVM или WASM. В то время как большинство архитектур содержат операции для работы с 32- и 64-битными числами, в EVM единственный тип данных — 256-битное целое число. Для реализации Ed25519 это очень удобно, так как все необходимые вычисления производятся над числами, помещающимися в 256 бит.

Как устроена подпись Ed25519

Ed25519 — подпись на основе и эллиптической кривой . Точки на кривой задаются парами координат
a38eb65b9f6ccdff295a05433949b325.svg
, причём вычисления с координатами производятся по модулю простого числа
839f25c2746382debd4f08ea25ad5ecf.svg
, равного
c9babef82849504d9e035c9c300a0f31.svg
— отсюда и название кривой. Для точек определена операция сложения, вместе с которой множество точек образует , размер которой равен
d67804422223a4f45c993bcf47f2e0f7.svg
, где
4104d65e0366e1792efd07e26a2f118d.svg
— простое число. При этом используется только подгруппа размера
30fb6814eea0091044df0e5de33dfbc2.svg
.


Через операцию сложения точек можно эффективно реализовать операцию умножения точки на число. Обратная же операция — поиск числа, которое, будучи умноженной на заданную точку, даст другую заданную точку — не имеет известного эффективного решения. На этом основана безопасность системы: закрытый ключ — это число
372e18546a3b7abb94c2672708bc5dfe.svg
, а открытый — точка
4637bbce172517a6fbfd7923feac966e.svg
(
560bd97f235311a36dff00db005e6ab5.svg
— фиксированная точка, имеющая порядок
30fb6814eea0091044df0e5de33dfbc2.svg
). Зная
372e18546a3b7abb94c2672708bc5dfe.svg
, можно легко вычислить
493c1c008018df9bed4910321f29ff00.svg
, в то время как обратная задача, вероятно, не имеет эффективного решения (хотя строго это не доказано).


Для создания подписи Ed25519 необходимо сгенерировать случайное число
066939a33475dd671b845469b6526972.svg
и вычислить точку
2df5351bf595e033bcae98d4d2994906.svg
. Затем вычислить от конкатенации
b81a7c1e9676b36cc02ddeea5d5f6e51.svg
,
493c1c008018df9bed4910321f29ff00.svg
(открытого ключа) и подписываемого сообщения:
5646ed374b510383e838efb4e83a03c7.svg
(здесь
bd236a859d121c85d92d1acf80ba3597.svg
— хеш-функция, а
1f40ec8c76d8ec7f636fb542a39a95d7.svg
обозначает конкатенацию, чтобы не путать со сложением). Затем вычислить
0106c06105838553471a4d602860d1f0.svg
— в этом месте необходим закрытый ключ. Значением подписи будет пара
1da3494bbc3c50c7222124c02000f233.svg
. На самом деле
066939a33475dd671b845469b6526972.svg
генерируется не случайно, а путём хеширования сообщения с частью закрытого ключа, но это делается для того, чтобы не зависеть от наличия безопасного генератора случайных чисел, и не влияет на правильность подписи.


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



Другое важное отличие EVM — стоимость операций. В то время как в «обычных» виртуальных машинах время выполнения отдельных инструкций может различаться от реализации к реализации, в EVM точное количество используемых ресурсов является частью спецификации, ведь за выполнение смарт-контрактов нужно платить пропорционально затраченным ресурсам. Затраты ресурсов измеряются величиной, называемой газ (gas), и, зная затраты газа для выполнения каждой инструкции, можно точно определить, сколько будет стоить выполнение той или иной программы. И здесь кроется ещё один подвох: относительная стоимость отдельных инструкций существенно отличается от того, что стоило бы ожидать для обычной программы. Например, для обычной программы стоило бы ожидать, что умножение 256-битных чисел, тем более по модулю, занимает на порядок больше времени, чем сложение. А в EVM эти операции стоят одинаково. Значит, обычные криптографические алгоритмы, которые оптимизируют для минимизации количества умножений, для EVM могут быть не оптимальны.


Ещё EVM предусматривает раздельные области хранения данных: код, память и хранилище (storage). Память доступна на чтение и запись, но её содержимое не сохраняется между выполнениями смарт-контракта. Код доступен только на чтение. Хранилище доступно на чтение и запись, но доступ к нему, даже на чтение, намного дороже, чем доступ к коду или памяти. Оптимальная реализация Ed25519 использует таблицы заранее подсчитанных данных. Если держать их в хранилище, то при каждом использовании придётся считывать их оттуда, а это дорого. Оптимально держать их в коде, но для этого нужно генерировать код на Solidity программой на каком-то другом языке (хотя Solidity и позволяет вычислять значения во время выполнения конструктора контракта и «зашивать» их в код, ограничения этой функции делают её неподходящей для этой цели). Кроме того, компилятор Solidity не умеет производить некоторые нужные оптимизации (например, разворачивание циклов), которые можно решить генерацией исходного кода. Поэтому я решил генерировать код на Solidity программой на JavaScript.


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

Первый шаг: хеш-функция SHA-512



Первая вещь, которую должна сделать функция проверки Ed25519 — посчитать хеш-функцию SHA-512. И даже это сделать нетривиально. В EVM доступна встроенная поддержка нескольких хеш-функций: SHA-256, Keccak-256 (почти SHA-3-256), даже древняя RIPEMD-160, а вот поддержки SHA-512 нет. И при попытке скомпилировать реализацию SHA-512 возникает проблема. EVM — это стековая машина, и Solidity хранит все локальные переменные на стеке. Хотя стек может содержать до 1024 элементов, напрямую можно обращаться только к последним 16 из них. Если попытаться обратиться к локальной переменной, расположение которой на стеке не входит в одну из последних 16, то код не скомпилируется — лично я считаю, что это серьёзная недоработка компилятора, но приходится пользоваться тем, что есть.


Вот псевдокод основной части SHA-512 — обработки блока:


w[0..15] := блок сообщения
for i in 16 .. 79:
s0 := (w[i-15] ror 1) ^ (w[i-15] ror 8) ^ (w[i-15] >> 7)
s1 := (w[i-2] ror 19) ^ (w[i-2] ror 61) ^ (w[i-2] >> 6)
w := w[i-16] + s0 + w[i-7] + s1

a, b, c, d, e, f, g, h := h0, h1, h2, h3, h4, h5, h6, h7
for i in 0 .. 79:
S0 := (a ror 28) ^ (a ror 34) ^ (a ror 39)
S1 := (e ror 14) ^ (e ror 18) ^ (e ror 41)
ch := (e & f) ^ (~e & g)
maj := (a & b) ^ (a & c) ^ (b & c)
temp1 := h + S1 + ch + k + w
temp2 := S0 + maj
a, b, c, d, e, f, g, h := temp1 + temp2, a, b, c, d + temp1, e, f, g
h0, h1, h2, h3 := h0 + a, h1 + b, h2 + c, h3 + d
h4, h5, h6, h7 := h4 + e, h5 + f, h6 + g, h7 + h


Как видно, во втором цикле используется больше 16 переменных, а ведь ещё есть промежуточные значения, которые тоже занимают место на стеке. Чтобы получить работающий код, пришлось использовать множество ухищрений: использовать блоки, чтобы ограничить время жизни переменных, менять порядок переменных таким образом, чтобы нужные переменные были ближе к концу стека, менять структуру выражений. Заодно можно слегка оптимизировать выражения: вместо (e & f) ^ (~e & g) использовать (e & (f ^ g)) ^ g, а вместо (a & b) ^ (a & c) ^ (b & c) — (a & (b | c)) | (b & c) (хотя можно и (a & (b ^ c)) ^ (b & c)). Для вычисления побитовых поворотов работает такой приём: чтобы вычислить побитовый поворот x, нужно взять число x | (x code> и вычислять его побитовые сдвиг (перед этим нужно очистить все биты x, кроме младших 64). Поскольку требуется вычислять по три побитовых поворота от одного и того же числа, выражение x | (x code> можно вычислить один раз и использовать три раза.


Поскольку в первом цикле для вычисления w используются значения не новее w[i-2] (т. е. w[i-1] не используется), можно вычислять элементы w парами (использовать 256-битные операции как своеобразный ). Тот же приём для побитовых поворотов работает, чтобы вычислять повороты от двух чисел сразу.


Общая схема алгоритма такая: использовать первые 16 значений w, затем вычислить следующие 16 значений, затем использовать их, и так далее. Сами значения w хранятся в четырёх переменных, упакованные по четыре, это позволяет избегать использования массивов. При этом внутренние циклы (по 16 итераций) развёрнуты, остаётся только внешний цикл, который выполняет 4,5 итерации.

Распаковка точек



Следующий этап: распаковка точек. Хотя точки на кривой Curve25519 задаются парами координат
a38eb65b9f6ccdff295a05433949b325.svg
, хранить обе координаты слишком расточительно: каждому значению
817b92407f764f57af9226e50cc788fd.svg
соответствует не более двух значений
9b34c4da5c757d4982bbd1b6f2e8998a.svg
, и наоборот. Поэтому точки хранятся так: значение
9b34c4da5c757d4982bbd1b6f2e8998a.svg
хранится целиком, а дополнительно хранится один бит, определяющий, какое из двух возможных значений
817b92407f764f57af9226e50cc788fd.svg
нужно использовать. Поскольку значения координат берутся по модулю
1d5fb48c410a6e32b2139334258c1670.svg
, такая запись помещается в 32 байта. Однако, для вычислений нужны обе координаты, поэтому нужно восстановить значение
817b92407f764f57af9226e50cc788fd.svg
.


Значение
817b92407f764f57af9226e50cc788fd.svg
восстанавливается по формуле
e21f5f0dbf8d0bc66f643308e35bafcf.svg
. Поскольку
ceb11128c38ebc350ac24b30d28ae06e.svg
не является квадратичным вычетом по модулю
839f25c2746382debd4f08ea25ad5ecf.svg
, значение знаменателя не может быть равно нулю, однако, значение квадратного корня существует только для чуть более половины возможных значений
9b34c4da5c757d4982bbd1b6f2e8998a.svg
. Здесь представляет интерес только деление и извлечение квадратного корня. Оказывается, эти операции можно совместить. Пусть необходимо вычислить
201453cd0d6cd87f09303e53856dd31a.svg
, причём
a0807b0fc13e411e3935805127031a24.svg
. Для этого предлагает такую процедуру (использующую тот факт, что
665d9e5e8dca725855493d2cc86795ac.svg
):

  • Посчитать
    d379588ccbb76209c6ab76a859ee69b1.svg
    .
  • Если
    fce611e517bfd9c5ad5b12e0ed180213.svg
    , то ответ равен
    1dd491b2ef892df0a581af9f5ebc0486.svg
    .
  • Иначе, если
    f3644c34d438f275516d0cfde20f74c6.svg
    , то ответ равен
    d0b43495c4ab0ca2426825b6554f86bd.svg
    .
  • Иначе, ответа не существует.


Почему это работает? Заметим, что
e2ccaf7f7493bade6a533133a9bf5db5.svg
, откуда следует, что
1fa0f73b0af180aad7960b8788755a45.svg
или
a8e8ad005cc05aa6194ea8ba0af89d7a.svg
. Если выполняется второе равенство (и
d08a434198528aac641afda1768f1b85.svg
), то квадратного корня из
8c1a3a5df0f73dd1226e764bb8d216f7.svg
не существует, поскольку
86810527d78297cd61deac5bc8203ad9.svg
не является квадратичным вычетом. Если
a01c5fb9ee41d1a8ba77e68aff2e45e4.svg
, то
7236c660b950aa72ad216744393b7161.svg
. Таким образом, в любом случае эта процедура находит ответ, если он существует.


Оказывается, эту процедуру можно слегка упростить: вместо
d379588ccbb76209c6ab76a859ee69b1.svg
вычислять
3ba7394c01a8d47db21ddefabf4ff485.svg
. При этом
956332bb419c5cab8488d64cee806f52.svg
, дальше доказательство полностью аналогично предыдущему варианту. Для этого вычисления используется вспомогательная функция, которая для произвольного значения
1c1778187263268cc347012d16da61e2.svg
вычисляет
e2b1e08fb963c5ccde704a8389faeddf.svg
и
7e55652a9fec0be7bd0aaf4e89559e02.svg
по модулю
839f25c2746382debd4f08ea25ad5ecf.svg
, эта же функция в дальнейшем используется для вычисления обратного по модулю
839f25c2746382debd4f08ea25ad5ecf.svg
(в этой функции реализована подобранная вручную аддитивная цепочка).

Двойное умножение



Вспомним, что для проверки подписи нужно проверить, что
03625ada1fe4b20213f5946cfbef1126.svg
. Кажется, что для этого нужно распаковать две точки (
b81a7c1e9676b36cc02ddeea5d5f6e51.svg
и
493c1c008018df9bed4910321f29ff00.svg
), но многие реализации, в том числе и эта, делают по-другому: вычисляют значение
6fda3f060b5183283183b760be0864b9.svg
, запаковывают и сравнивают с запакованным значением
b81a7c1e9676b36cc02ddeea5d5f6e51.svg
. Таким образом, всё, что остаётся — это вычислить
6fda3f060b5183283183b760be0864b9.svg
.


Для вычисления такого выражения есть несколько методов. Можно посчитать отдельно
13a34ef516aeca8dedbed7ecc4b6ef49.svg
и отдельно
9f810d1adc8fa5dae569e3379051b232.svg
, но это неэффективно. Более эффективный метод — «удвоить и прибавить» (double-and-add), в псевдокоде это выглядит примерно так:


result := 0
for bit_index in n-1 .. 0:
result := double(result)
if s & (1code>


Здесь
08d9faefbe272bdf8fbb80773542e343.svg
— число бит в
f9dda26950cb67bd3ecef956c5341c14.svg
и
5174f49cc4f41fc5c4802c76f4c76c5a.svg
. Этот алгоритм сканирует биты в
f9dda26950cb67bd3ecef956c5341c14.svg
и
5174f49cc4f41fc5c4802c76f4c76c5a.svg
, начиная со старшего, и при обнаружении единичного бита прибавляет
560bd97f235311a36dff00db005e6ab5.svg
к текущему результату (или вычитает
493c1c008018df9bed4910321f29ff00.svg
). Этот алгоритм выполняет
08d9faefbe272bdf8fbb80773542e343.svg
удвоений и
08d9faefbe272bdf8fbb80773542e343.svg
(в среднем) сложений, если считать, что биты чисел
f9dda26950cb67bd3ecef956c5341c14.svg
и
5174f49cc4f41fc5c4802c76f4c76c5a.svg
принимают значения
f9c3c8e488ead4696749012f5ece6d13.svg
и
3e4c77a6e7c579a778fa84a18b6f4be0.svg
с равной вероятностью. Но и его можно ускорить. Заметим, что каждым сложением он «покрывает» один бит в числе
f9dda26950cb67bd3ecef956c5341c14.svg
или
5174f49cc4f41fc5c4802c76f4c76c5a.svg
, а можно сделать так, чтобы он покрывал сразу несколько. А именно, если для некоторого
16da507b2fc389688ef0659939dcc647.svg
предпосчитать кратные
493c1c008018df9bed4910321f29ff00.svg
и
560bd97f235311a36dff00db005e6ab5.svg
с коэффициентами от
62949cc2886126a1d03b7c9a0a24c515.svg
до
6cee245c67203d121d50ef9e566e21e1.svg
, то прибавлением такого кратного можно обработать сразу
44c562936a615ceeecfca5c080e31de8.svg
бит! Это выглядит примерно так:


result := 0
for bit_index in n-1 .. k:
result := double(result)
if s & (1> (bit_index-k)])
s := s & ((1 > (bit_index-k)])
h := h & ((1 code>


Этот алгоритм, обнаружив единичный бит, обрабатывает сразу и его, и
16da507b2fc389688ef0659939dcc647.svg
следующих бит. Затем он «вырезает» эти биты из текущего значения
f9dda26950cb67bd3ecef956c5341c14.svg
или
5174f49cc4f41fc5c4802c76f4c76c5a.svg
, чтобы не обрабатывать их повторно. Правда, у этого алгоритма есть небольшая проблема:
16da507b2fc389688ef0659939dcc647.svg
младших бит могут быть не обработаны. Для решения этой проблемы я использовал два подхода:

  • Для
    f9dda26950cb67bd3ecef956c5341c14.svg
    и
    560bd97f235311a36dff00db005e6ab5.svg
    и воспользовался тем, что кратные
    560bd97f235311a36dff00db005e6ab5.svg
    посчитаны заранее и зашиты в код. Я умножил значение
    f9dda26950cb67bd3ecef956c5341c14.svg
    на
    62949cc2886126a1d03b7c9a0a24c515.svg
    , а кратные
    560bd97f235311a36dff00db005e6ab5.svg
    — на
    dfaea8b15069e25c7ed3993a976650f0.svg
    . Результат не меняется, но после умножения на
    62949cc2886126a1d03b7c9a0a24c515.svg
    младшие
    16da507b2fc389688ef0659939dcc647.svg
    бит у
    f9dda26950cb67bd3ecef956c5341c14.svg
    гарантированно нулевые.
  • Для
    5174f49cc4f41fc5c4802c76f4c76c5a.svg
    и
    493c1c008018df9bed4910321f29ff00.svg
    этот способ не работает. Я рассматривал вариант умножить
    5174f49cc4f41fc5c4802c76f4c76c5a.svg
    на
    dfaea8b15069e25c7ed3993a976650f0.svg
    и затем на
    62949cc2886126a1d03b7c9a0a24c515.svg
    , но проблема в том, что
    493c1c008018df9bed4910321f29ff00.svg
    является частью входных данных и не обязательно лежит в подгруппе размера
    30fb6814eea0091044df0e5de33dfbc2.svg
    . Если
    493c1c008018df9bed4910321f29ff00.svg
    не лежит в подгруппе размера
    30fb6814eea0091044df0e5de33dfbc2.svg
    , то умножение на
    173fd6e07a6248d512fc5f09560d3e5c.svg
    не эквивалентно умножению на
    5174f49cc4f41fc5c4802c76f4c76c5a.svg
    , а если вместо
    30fb6814eea0091044df0e5de33dfbc2.svg
    использовать
    b0d261270ce99ba78556ca5e0058e9d8.svg
    (полный размер группы), то по этому модулю нельзя вычислить
    0ffb9ca75b13042cfe0bc1a2c50c3d45.svg
    . В итоге, я решил сделать так: вместо
    5174f49cc4f41fc5c4802c76f4c76c5a.svg
    использовать
    011501324b9e292785afdda2f8407db1.svg
    (то же самое, что
    a31392f71d06b0a578ef032f33269d57.svg
    по модулю
    b0d261270ce99ba78556ca5e0058e9d8.svg
    ), а в конце сделать ещё одно сложение: result := subtract(result, Amul[(1 code>, то есть дообработать то, что осталось от
    16da507b2fc389688ef0659939dcc647.svg
    младших бит, при этом прибавка
    62949cc2886126a1d03b7c9a0a24c515.svg
    нужна, потому что кратные
    493c1c008018df9bed4910321f29ff00.svg
    посчитаны только для коэффициентов от
    62949cc2886126a1d03b7c9a0a24c515.svg
    до
    6cee245c67203d121d50ef9e566e21e1.svg
    .


В этой реализации я использую значение
c708e5434f493356304a46d3301ef8b0.svg
, которое обеспечивает баланс между затратами на предпосчёт, использованием памяти и экономией вычислений в основном цикле.


Следующий вопрос: как реализовать сложение, вычитание и удвоение точек. Обычные реализации в криптографических библиотеках оптимизированы в расчёте на то, что умножение по модулю
839f25c2746382debd4f08ea25ad5ecf.svg
намного дороже сложения. В EVM эти операции стоят почти одинаково, поэтому стоило подобрать формулу вручную. В этом очень помогает база данных формул для операций на эллиптических кривых — . Вот для того типа кривой, к которым относится Curve25519. Как видно, в качестве источника для всех формул указана . В этой статье содержится важная информация, которой нет в EFD, в частности, о том, какие формулы являются полными, а какие нет. Неполные формулы представляют опасность: они будут работать на случайных тестах, но на специально подобранных входных данных они могут дать неправильный результат. В частности, формулы для сложения, у которых наименование заканчивается на -2 или -4 ( ), являются неполными, поэтому их использовать не стоит (хотя они и более эффективные). В итоге я использовал в качестве основы формулу «madd-2008-hwcd-3» для сложения и «dbl-2008-hwcd» для удвоения (там нет большого выбора). Однако, я внёс некоторые изменения.


Во-первых, я изменил используемые координаты. Там предлагается использовать так называемые расширенные проективные координаты (extended projective coordinates) — такие числа
6d6a4f78fbacd6edecc018ce8ad3e364.svg
,
c62ff25ef4caeaeaef7122a489ef9d07.svg
,
27425a3cdf6cdf9dea5baa307429b5a7.svg
и
175f98839ab732db76d5f20cd6ce2ce9.svg
, что
7864d81fe722fc4be54de2c0a5193162.svg
,
43a6eaaa6c097b4a88488a6bb8629020.svg
и
c9e7ba0af2ba3f4e063b41a0e2ae6bb6.svg
. Однако, в формуле для удвоения
175f98839ab732db76d5f20cd6ce2ce9.svg
не используется, поэтому от его вычисления можно попробовать избавиться, если следующая операция — удвоение. Для этого можно заметить, что как при сложении, так и при удвоении результат сначала вычисляется в виде координат
6d6a4f78fbacd6edecc018ce8ad3e364.svg
,
d07cc5b26db621faab45e0e0b54ede62.svg
,
c62ff25ef4caeaeaef7122a489ef9d07.svg
и
836e59247dee9dc566df40f0f1d606e8.svg
, таких что
61ca01eecd0af8cd72d8d682509b49fa.svg
и
97bd0ec1355fd463890a8bf9e3ddc0c9.svg
, а затем эти координаты переводятся в обычные, для этого требуется четыре умножения (или три, если не вычислять
175f98839ab732db76d5f20cd6ce2ce9.svg
). Я решил хранить точки в виде
6d6a4f78fbacd6edecc018ce8ad3e364.svg
,
d07cc5b26db621faab45e0e0b54ede62.svg
,
c62ff25ef4caeaeaef7122a489ef9d07.svg
,
836e59247dee9dc566df40f0f1d606e8.svg
, это позволяет сэкономить одно умножение при удвоении из-за отсутствия необходимости вычислять
175f98839ab732db76d5f20cd6ce2ce9.svg
.


Во-вторых, я продумал формат для предпосчитанных точек. Формулы madd реализуют так называемое смешанное сложение (mixed addition) — сложение, при котором можно заранее посчитать часть промежуточных значений для одной из точек, чтобы уменьшить количество вычислений. Мне это очень удобно: у меня восемь точек посчитано ещё на этапе генерации кода и ещё восемь генерируется во время выполнения; переведя их в оптимальную для вычислений форму, можно сэкономить на сложениях в основном цикле, которых около ста. Я выбрал такое представление:
f17c3b414df08c6cec01a832da9ab0ee.svg
. По сравнению с некоторыми более очевидными представлениями (например,
125471fc83873da99827413f02f57f6f.svg
, которое используется в libsodium) это позволяет сэкономить одно умножение на 2, которое в EVM стоит, как обычное умножение. В результате псевдокод для сложения выглядит так:


// Входные данные:
// Точка 1: (x1, u1, y1, v1), x=x1/u1, y=y1/v1.
// Точка 2: (s2, d2, t2), s2=(y+x)/2, d2=(y-x)/2, t2=x*y*d.
// Выходные данные:
// Точка 3: (x3, u3, y3, v3), x=x3/u3, y=y3/v3.

// Точка (x4, y4, z4, t4) - то же самое, что и точка 1, но в других координатах.
x4 := x1 * v1
y4 := y1 * u1
z4 := u1 * v1
t4 := x1 * y1

// Далее см. формулы madd-2008-hwcd-3.
s4 := y4 + x4
d4 := y4 - x4
a := d4 * d2 // (y4-x4)*(y2-x2)/2, соответствует A/2.
b := s4 * s2 // (y4+x4)*(y2+x2)/2, соответствует B/2.
c := t4 * t2 // Соответствует C/2.
// D/2 - это просто z4, вычислять не надо.
x3 := b - a // E/2.
u3 := z4 + c // G/2.
y3 := b + a // H/2.
v3 := z4 - c // F/2.
// Значения x3, u3, y3, v3 отличаются от E, G, H, F коэффициентом 1/2, но это не важно, потому что значения x3/u3 и y3/v3 те же самые.


Псевдокод для удвоения выглядит так:


// Входные данные:
// Точка 1: (x1, u1, y1, v1), x=x1/u1, y=y1/v1.
// Выходные данные:
// Точка 2: (x2, u2, y2, v2), x=x2/u2, y=y2/v2.

// Точка (x3, y3, z3) - то же самое, что и точка 1, но в других координатах. Значение t3 не нужно.
x3 := x1 * v1
y3 := y1 * u1
z3 := u1 * v1

// Далее см. формулы dbl-2008-hwcd.
xx := x3 * x3 // A.
yy := y3 * y3 // B.
xy := x3 * y3 // А вот здесь выгоднее вычислить E как 2xy, а не так, как там.
zz := z3 * z3
x2 := xy + xy // E.
u2 := yy - xx // G.
y2 := yy + xx // -H.
v2 := zz + zz - u2 // -F.
// Опять же, коэффициент -1 у значений y2 и v2 не влияет на правильность результата.


Ещё одна оптимизация: для сложения не обязательно использовать инструкцию addmod (сложение по модулю). Если слагаемые меньше, чем
fa11e494df43a4d7b4353cb8b3121814.svg
, то можно использовать обычное сложение (оно дешевле), так как результат гарантированно не переполнит 256-битный тип. Результат такого сложения, однако, можно использовать только в операциях, использующих значение по модулю. Например, если нужно сложить три числа, то из двух сложений по модулю можно заменить на обычное сложение только одно.

Проверка результата



Наконец, полученную точку нужно упаковать. Это несложно: посчитать
61ca01eecd0af8cd72d8d682509b49fa.svg
и
97bd0ec1355fd463890a8bf9e3ddc0c9.svg
, затем записать младший бит
817b92407f764f57af9226e50cc788fd.svg
на место старшего бита
9b34c4da5c757d4982bbd1b6f2e8998a.svg
. Поскольку нахождение обратного по модулю — затратная операция (она реализована посредством возведения в степень
b971493d0b7474568e9c48471dc63996.svg
), используется оптимизация, позволяющая находить обратное только один раз: для этого вычисляется
8af85f4ed06e0b5a69649439ac54a61c.svg
, из него можно вычислить
68741a682c45558505dcea9a77b4b3a0.svg
и
04873e2083158ab5f647f343e47dc7f9.svg
. Упакованная точка сравнивается со значением
b81a7c1e9676b36cc02ddeea5d5f6e51.svg
, записанным в подписи. Если совпала, то подпись правильная. Вот и всё.

Заключение



К счастью, в NEAR нет нужды в подобных извращениях с кодом. Смарт-контракты можно писать на Rust и AssemblyScript (похож на TypeScript) с использованием существующих библиотек.


Посмотреть, как выглядит разработка под NEAR, и поэкспериментировать в онлайн-IDE можно .


Следить за всеми новостями на русском можно в и в , а на английском в .


До скорых встреч!
 
Сверху Снизу