Evita ataques de fuerza bruta en ZK: No cometas este error 🙅
Ahmed Castro
Posted on September 26, 2024
Al comenzar nuestra jornada en el desarrollo de aplicaciones ZK, puede ser difícil adaptarnos a esta nueva manera de escribir aplicaciones que se centran en la privacidad. En este artículo, exploraremos uno de los errores más comunes en este ámbito: escribir circuitos sujetos al ataque de fuerza bruta.
Para ilustrarlo, construiremos una aplicación de préstamos sin colateral, donde una empresa puede emitir pruebas privadas de salario. Estas pruebas permiten que el empleado las presente en protocolos DeFi y reciba préstamos de forma automática, sin comprometer la privacidad de sus datos financieros.
Implementación ingenua y equivocada 🙅
Material de apoyo: Mi guía completa sobre ZK
Una de las primeras intuiciones de los desarrolladores provenientes de web2 o web3 sería declarar una variable privada income
que represente el salario del empleado.
Aunque esta variable income
es importante, no resuelve completamente el problema. Para hacerlo, necesitamos introducir un mecanismo adicional que veremos más adelante. Por ahora, examinemos cómo construir un circuito básico con esta idea y cuáles serían sus problemas.
El siguiente circuito devuelve 1
si el income
es mayor a 2000
, de lo contrario devuelve 0
. Combinandolo con un smart contract podríamos otorgar préstamos de manera autómatica a usuarios que tengan un salario mayor a, por ejemplo 2000$. Pero si prestas atención tiene dos grandes fallas:
-
No hay una manera de verificar que el
income
es auténtico: Nada impide que un usuario declare cualquier valor deincome
, lo que hace imposible validar de la prueba. -
El
income
puede ser descubierto mediante un ataque de fuerza bruta: En la mayoría de los backends ZK, sería posible revelar el valor de unincome
en una prueba simplemente generando muchas pruebas de manera secuencial hasta encontrar una que sea igual a una prueba antes enviada on-chain. Revelando así el salario de cada usaurio.
pragma circom 2.0.0;
include "circomlib/circuits/comparators.circom";
template NaiveCreditCheck() {
signal input income;
signal output isEligible;
component gtComponent = GreaterThan(32);
gtComponent.in <== [income, 2000];
isEligible <== gtComponent.out;
}
component main = NaiveCreditCheck();
Solución
Ambos problemas pueden ser solucionados al introducir un salt
como parámetro privado. El salt
es una llave privada que, hasheándolo con el income
, protege el valor real del salario y evita ataques de fuerza bruta.
En el circuito a continuación hasheamos el salt
junto con el income
para mantenerlo seguro ante ataques de fuerza bruta. Como ya sabemos, los algorimos de hasheo nos permiten ofuscar valores de manera que a partir de salt
e income
podemos recontruir el publicHash
. Pero a partir del publicHash
no podemos saber el salt
e income
.
publicHash
juega un rol importante, pues es una variable pública que será almacenada en un smart contract controlado por el empleador y será verificado por el protocolo DeFi.
creditCheck.circom
pragma circom 2.0.0;
include "circomlib/circuits/comparators.circom";
include "circomlib/circuits/poseidon.circom";
template SecureCreditCheck() {
signal input salt;
signal input income;
signal input publicHash;
signal output isEligible;
component gtComponent = GreaterThan(32);
gtComponent.in <== [income, 2000];
component poseidonComponent = Poseidon(2);
poseidonComponent.inputs <== [salt, income];
log(poseidonComponent.out);
assert(poseidonComponent.out == publicHash);
isEligible <== gtComponent.out;
log(isEligible);
}
component main {public [publicHash]} = SecureCreditCheck();
Genera una prueba
Antes de continuar, instala circom si aún no lo tienes
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
git clone https://github.com/iden3/circom.git
cd circom
cargo build --release
cargo install --path circom
npm install -g snarkjs
Crea tu archivo de entrada e ingresa los valores correspondientes. Ten en cuenta que publicHash
es la combinación de salt
e income
. Si deseas modificar alguno de los inputs, deberás regenerar la prueba, lo que imprimirá en la consola el hash en la línea log(poseidonComponent.out);
. Obviamente, esta prueba fallará inicialmente, así que asegúrate de actualizarla, y esta vez funcionará correctamente.
input.json
{
"salt": "123",
"income": "2100",
"publicHash": "6503990210857427912452445629871225279898500973314213585899222629849816319239"
}
Asegúrate de instalar la dependencia circomlib
que nos proporciona la función GreaterThan
y la implementación de Poseidon.
git clone https://github.com/iden3/circomlib.git
Ahora realizamos la trusted setup.
circom creditCheck.circom --r1cs --wasm --sym --c
node creditCheck_js/generate_witness.js creditCheck_js/creditCheck.wasm input.json witness.wtns
snarkjs powersoftau new bn128 12 pot12_0000.ptau -v
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="First contribution" -v -e="123"
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v
snarkjs groth16 setup creditCheck.r1cs pot12_final.ptau creditCheck_0000.zkey
snarkjs zkey contribute creditCheck_0000.zkey creditCheck_0001.zkey --name="1st Contributor Name" -v -v -e="123"
snarkjs zkey export verificationkey creditCheck_0001.zkey verification_key.json
snarkjs groth16 prove creditCheck_0001.zkey witness.wtns proof.json public.json
snarkjs groth16 verify verification_key.json public.json proof.json
snarkjs generatecall
Al finalizar, en la terminal se mostrará una prueba en el formato que la espera Remix como la que se muestra a continuación. Usaremos esta prueba más adelante en este artículo.
["0x132fcb46c5367914fba5b87838a810834952017b0f4a077fac783036ed5b6f4b", "0x1ec919d24da6b8fd247d1a121e60eafce18f436677f9b5dbeee5f4f9e887d7aa"],[["0x15fb725e134fc3190beb3cef4bc7554c759b29797bcd951de786b3e80f230c89", "0x15e50cc61eb63094ffd3b38a7b6ba6d22a614220ee0d45ed9604c1ae47ec268d"],["0x0898c8cf1920275203bec3c4e7bcdf0316448da37f3241500756945f3f236a57", "0x303c7e6da24dc86e8f40cb74ffa43885bb202ea72ddaa52030f022c29f50059d"]],["0x054b51b0b19ee17a7568209045e50b0766f715a5df6a3f35f6aff7aa91c1e987", "0x05322e325708ca2e1b93bd1e509333f8993345794c7229f164b0e30b7a0c51cc"],["0x0000000000000000000000000000000000000000000000000000000000000001","0x0e6120c4f0f48c3d7ac1f6d1d2c364b6c7af3906375777d309f881a28f7f4507"]
Luego genera el contrato verificador ZK en verifier.sol
. Lánzalo on-chain.
snarkjs zkey export solidityverifier creditCheck_0001.zkey verifier.sol
Obtén el préstamo on-chain
Lanzamos el contrato del emisor de las pruebas de salario. En este tutorial usaremos remix.
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract ProofOfSalary is Ownable {
constructor() Ownable(msg.sender) {}
mapping(uint proof => address employeeAccount) salaryProofs;
function addProof(uint proof, address employee) public onlyOwner {
salaryProofs[proof] = employee;
}
function getAddress(uint proof) public view returns(address) {
return salaryProofs[proof];
}
}
El empleador puede colocar pruebas de salario asociadas con una cuenta que será capaz de claimear los préstamos.
Ahora lanzamos el contrato de préstamos. En el constructor pasamos los dos contratos que recién lanzamos: El verificador de Circom y el de de ProofOfSalary
.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
interface ICircomVerifier {
function verifyProof(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[2] calldata _pubSignals) external view returns (bool);
}
interface IProofOfSalary {
function getAddress(uint proof) external view returns(address);
}
contract ZKLoan is ERC20, ERC20Burnable, Ownable {
ICircomVerifier circomVerifier;
IProofOfSalary proofOfSalary;
uint public publicInput;
mapping(uint publicHash => bool isNullified) nullifiers;
constructor(address circomVeriferAddress, address proofOfSalaryAddress)
ERC20("Debt Token", "DT")
Ownable(msg.sender)
{
circomVerifier = ICircomVerifier(circomVeriferAddress);
proofOfSalary = IProofOfSalary(proofOfSalaryAddress);
}
// Public functions
function getLoan(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[2] calldata _pubSignals) public {
circomVerifier.verifyProof(_pA, _pB, _pC, _pubSignals);
bool isEligible = _pubSignals[0] == 1;
uint publicHash = _pubSignals[1];
require(isEligible, "Not eligible");
require(!nullifiers[publicHash], "Loan already processed");
nullifiers[publicHash] = true;
address recipient = proofOfSalary.getAddress(publicHash);
_mint(recipient, 1 ether);
(bool sent, bytes memory data) = msg.sender.call{value: 1 ether}("");
data;
require(sent, "Failed to send Ether");
}
function repayLoan() payable public {
require(msg.value == 1 ether);
_burn(msg.sender, 1 ether);
}
// Owner functions
function deposit() public payable onlyOwner {
}
function withdraw(uint amount) public onlyOwner {
(bool sent, bytes memory data) = msg.sender.call{value: amount}("");
data;
require(sent, "Failed to send Ether");
}
}
Ahora deposita al menos 1 ether, pasándolo como value
Deposítalo llamando la función deposit
.
Pasa los parámetros que obtuvimos en la terminal anteriormente para sacar el préstamo llamando la función getLoan
.
Observa cómo obtuviste la deuda en formato del token ERC20.
Finalmente podrás repagarla pasando de vuelta 1 ether como parámetro y verás que tus tokens de deuda serán quemados.
Preguntas frecuentes
¿Puede el Salt ser la llave privada de mi wallet?
¡Sí puede! De hecho, sería lo ideal pues si no lo es nos tocará guardarla en algún lugar seguro, al igual que nuestra llave privada o 12 palabras. El problema es que las wallets, con justa razón, no tienen ningún mecanismo para otorgarle las llaves privadas a una aplicación. Es por eso que usualmente archivos, también llamados "notes", que descargamos y sirven como salt
o llaves privadas. También existen mecanismos de firmas con ECDSA. Cabe mencionar que en mi opinión este es el hoy el problema más grande en la UX de ZK.
¿Qué son los nullifiers
?
Si prestaste atención al contrato ZKLoan
a detalle, habrás observado que tiene una variable tipo mapping
llamada nullifiers
. Esta almacena todos los hashes publicos a los que se le han entregado préstamos anteriormente, esto se lleva a cabo den la función getLoan
que también valida que solo se otorgue un préstamos por persona. Los nullifiers son un mecanismo común en construcciones ZK.
Conclusiones
Es imporante comprender cómo nuestros datos privados pueden ser expuestos a través de ataques de fuerza bruta si no utilizamos un salt
o un mecanismo similar. Esto es especialmente relevante para conjuntos de datos finitos y pequeños, incluyendo salarios, edades, países de residencia, o miembros de grupos, como los holders de NFTs.
En este tutorial, aprendimos a proteger la privacidad utilizando una función de hashing como Poseidon. Si te interesa profundizar en el tema de la privacidad y ZK, te invito a ver mi guía completa de ZK y a seguirme aquí en dev.to para estar al tanto de mis nuevas publicaciónes.
Además, tocamos el tema de los préstamos on-chain. En este caso, no generamos intereses sobre el préstamo, pero si te interesa el mundo de los préstamos on-chain, te recomiendo completar mi guía sobre Aave, que permite a los usuarios generar intereses de manera intuitiva a través de su token que utiliza mecanismos de Rebase.
¡Gracias por ver este tutorial!
Sígueme en dev.to y en Youtube para todo lo relacionado al desarrollo en Blockchain en Español.
Posted on September 26, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 27, 2024