Verificación de Firmas y Ejecución Segura de Transacciones en Contratos Inteligentes: Un Caso Práctico con ECDSA en Solidity

Cristian Mendoza
9 min readAug 9, 2024

Antes de explicar acerca de Hashing y firmas digitales, tenemos que ir un poco atras en la historia de la cryptografia.

Históricamente, hasta la década de 1970, la criptografía se centraba en el cifrado de mensajes para evitar que fueran descifrados si eran interceptados. Se utilizaba principalmente para transmitir información sensible, especialmente en contextos militares.

El proceso consistía en que el remitente tomaba su mensaje y lo pasaba a través de una función de cifrado para producir una salida cifrada. Este método podía ser tan simple como desplazar cada carácter de una cadena una posición en el alfabeto. Por ejemplo, “abc” se convertiría en “bcd”. Aunque este tipo de cifrado no es especialmente difícil de romper, su eficacia dependía del secreto de la función utilizada.

A medida que la criptografía avanzaba con los años, se introdujeron funciones más complejas para ocultar mejor los mensajes. Un avance importante fue la idea de la clave secreta.

Si dos partes se reúnen antes de intercambiar mensajes, pueden llegar a un acuerdo sobre una clave concreta. Esta clave y una función (como el cambio de alfabeto mencionado anteriormente) podrían utilizarse conjuntamente para crear un cifrado más seguro.

Tener claves en ambos lados del mensaje se considera criptografía de clave simétrica. (symmetric-key cryptography)

Como ya se ha dicho, la criptografía más avanzada eran versiones cada vez más complejas de la criptografía de clave simétrica. El objetivo del juego era no dejar nunca que tu adversario tuviera tu clave. Y, por supuesto, en situaciones militares, ¡los países hacían todo lo posible por descifrar tu código!

Con la llegada de la informática personal en el horizonte, algunos criptógrafos empezaron a pensar con originalidad. La gente querría poder comunicarse entre sí de forma segura sin tener que preocuparse por los fisgones. Por supuesto, podían reunirse en algún lugar en persona e intercambiar claves cada vez que quisieran hablar de forma segura, pero esto parecía un poco anticuado.

Esto hizo pensar a los criptógrafos: ¿cómo podrían comunicarse dos partes de forma segura sin haberse reunido previamente para intercambiar claves?

En 1976 Whitfield Diffie propuso una idea. ¿Y si existiera una clave pública?

Muchos criptógrafos establecidos descartaron esta idea de plano. Al fin y al cabo, el objetivo de una clave de cifrado era mantenerla en secreto.

Resulta que una clave pública introduce algunas propiedades criptográficas extremadamente importantes.

Digamos que hay una clave privada que puede descifrar un mensaje de una clave pública y viceversa. Cada clave es la única que puede descifrar un mensaje cifrado por la otra clave.

Ahora imaginemos que Bob ha declarado una clave pública como la clave que le identifica. Bob mantendrá una clave privada que corresponde a su clave pública. Cuando utilice su clave privada para cifrar un mensaje, podrá compartirlo públicamente para que sea descifrado utilizando su clave pública. Al descifrar este mensaje, podemos afirmar sin lugar a dudas que sólo Bob pudo escribirlo. La única clave que podría haber cifrado el mensaje es la clave privada correspondiente, a la que sólo Bob tiene acceso. En la práctica, esto crearía una firma digital infalsificable para Bob.

Por otro lado, ¿qué pasaría si se cifrara un mensaje utilizando la clave pública de Bob? Por supuesto, cualquiera puede hacerlo, ya que la clave pública de Bob está disponible para todo el mundo. La ventaja es que sólo Bob puede descifrar el mensaje. De este modo, un amigo de Bob puede escribir un mensaje que sólo pueda leer Bob. Podrían enviarlo a través de cualquier red, independientemente de su seguridad, con tal de que llegue a Bob. Podrían estar seguros de que nadie podría descifrar el mensaje excepto Bob.

Esta fue la idea que se le ocurrió a Whitfield Diffie en 1976. El único problema era que no tenía ninguna forma práctica de hacerlo realidad. Tenía un concepto, pero no una función matemática con esas propiedades. Diffie trabajaría con Martin Hellman y Ralph Merkle en la búsqueda de ese sistema.

A diferencia de las técnicas de cifrado mencionadas en la sección anterior, la criptografía de clave pública se considera un cifrado asimétrico, ya que sólo una de las partes tiene acceso a la clave privada.

RSA and ECDSA

Hoy en día, tanto RSA como ECDSA son dos algoritmos muy utilizados para la criptografía de clave pública.

El algoritmo RSA se basa en la idea de que es muy fácil encontrar el producto de dos números primos, pero extremadamente difícil factorizar esos dos números primos si se tiene el producto.

Las matemáticas que hay detrás de estos algoritmos pueden ser bastante difíciles de entender. La dificultad de descifrar el algoritmo RSA sigue siendo un misterio sin resolver en Informática. Se supone que solo puede descifrarse en un tiempo exponencial (en relación con el tamaño de la entrada), lo que básicamente se reduce a un ataque de fuerza bruta consistente en adivinar la clave al azar.

El algoritmo ECDSA utiliza curvas elípticas. Puede proporcionar el mismo nivel de seguridad que otros algoritmos de clave pública con tamaños de clave más pequeños, razón por la que se ha hecho bastante popular. Es el Algoritmo de Firma Digital utilizado por Bitcoin, concretamente la curva secp256k1.

Public Key to Address

Tanto Bitcoin como Ethereum tienen un proceso de transformación para tomar una clave pública y convertirla en una dirección. En el caso de Bitcoin, incluye una suma de comprobación y la codificación Base58. La transformación de la dirección de Ethereum es bastante más simple, su dirección son los últimos 20 bytes del hash de la clave pública.

Lo importante a reconocer aquí es que la dirección se diferencia de la clave pública, pero siempre se puede derivar la dirección si se tiene la clave pública.

const { keccak256 } = require("ethereum-cryptography/keccak");

function getAddress(publicKey) {
return keccak256(publicKey.slice(1)).slice(-20);
}

module.exports = getAddress;

Importación de la función keccak256

const { keccak256 } = require("ethereum-cryptography/keccak");

Aquí estamos importando la función keccak256 del paquete ethereum-cryptography. Keccak256 es una función de hash criptográfico que se utiliza ampliamente en Ethereum para asegurar la integridad y autenticidad de los datos.

Definición de la función getAddress

function getAddress(publicKey) {
return keccak256(publicKey.slice(1)).slice(-20);
}

Esta función toma como parámetro una clave pública (publicKey) y retorna la dirección Ethereum correspondiente.

publicKey.slice(1)

  • La clave pública en Ethereum es un punto en la curva elíptica y, generalmente, está representada en un formato que incluye un byte de prefijo para indicar si la clave es comprimida o no. La función slice(1) elimina este byte de prefijo, tomando solo los bytes relevantes de la clave pública para el hash.
  • La función keccak256 se aplica a la clave pública (sin el byte de prefijo) para obtener el hash de 32 bytes (256 bits).

keccak256(publicKey.slice(1)).slice(-20)

  • En Ethereum, la dirección se obtiene tomando los últimos 20 bytes del hash keccak256 de la clave pública. La función slice(-20) extrae estos 20 bytes del final del hash.

Ejemplo usando solidity

Este contrato, llamado HashOperationAndExecute, está diseñado para verificar la firma de una operación de usuario y, si la firma es válida, ejecutar la transacción asociada en la blockchain.

Explicación Breve:

  1. Propósito: El contrato permite que un propietario firme operaciones de usuarios, las verifique utilizando la criptografía de ECDSA, y ejecute transacciones si la firma es válida.
  2. Estructura UserOperation: Define los parámetros de una operación de usuario, como el límite de gas, el precio del gas, el destino (to), los datos de la transacción, la firma, y el valor transferido.
  3. Función hashOperation: Crea un hash único de la operación de usuario utilizando keccak256. Este hash es único para cada operación, asegurando que cualquier cambio en los detalles de la operación resulte en un hash diferente.
  4. Función verifiySignature: Verifica que la firma del usuario coincida con la operación utilizando la librería ECDSA. Si la firma es válida y fue firmada por el propietario, la función devuelve true.
  5. Función VerifyAndExecute: Verifica la firma usando verifiySignature y, si es válida, ejecuta la transacción en la red. Si la firma es inválida o la transacción falla, se lanza un error.
  6. Propietario: Solo el propietario, definido al desplegar el contrato, puede llamar a la función VerifyAndExecute.

Este contrato es útil para situaciones donde se requiere la verificación de firmas antes de ejecutar transacciones, asegurando que solo operaciones autorizadas por el propietario se lleven a cabo en la blockchain.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

struct UserOperation {
uint256 gasLimit;
uint256 gasPrice;
uint256 nonce;
address to;
bytes data;
bytes signature;
uint256 value;
}
/*
* @title HashOperationAndExecute
* @dev HashOperationAndExecute contract is used to verify the signature of the user operation and execute the transaction
*/

contract HashOperationAndExecute {
address public immutable _ownerAndSigner;

constructor(address ownerAndSigner) {
_ownerAndSigner = ownerAndSigner;
}

receive() external payable {}

modifier OnlyOwner() {
require(
msg.sender == _ownerAndSigner,
"Only owner can call this function"
);
_;
}

/*
esta funcion se encarga de hashear la operacion del usuario, usando keccak256
en caso tal que hashOperation sea igual a la firma del usuario, se procede
a ejecutar la transaccion, en caso contrario se lanza un error, recuerda que
el hash de la operacion del usuario es unico, ya que keccak256 es una funcion
deterministica, si la operacion del usuario cambia, el hash tambien cambiara
*/

function hashOperation(
UserOperation memory userOperation
) public pure returns (bytes32)
{
return
keccak256(
abi.encode(
userOperation.gasLimit,
userOperation.gasPrice,
userOperation.nonce,
userOperation.to,
keccak256(userOperation.data),
userOperation.value
)
);
}
/*
esta funcion se encarga de verificar la firma del usuario, para ello se
hace uso de la libreria ECDSA, en caso tal que la firma sea correcta, se
procede a ejecutar la transaccion, en caso contrario se lanza un error
*/

function verifiySignature(
UserOperation memory userOperation
) public view returns (bool)
{
bytes32 hash = MessageHashUtils.toEthSignedMessageHash(
hashOperation(userOperation)
);
return ECDSA.recover(hash, userOperation.signature) == _ownerAndSigner;
}
/*
esta funcion se encarga de verificar la firma del usuario, en caso tal que
la firma sea correcta, se procede a ejecutar la transaccion, en caso contrario
se lanza un error
*/

function VerifyAndExecute(
UserOperation memory userOperation
) public OnlyOwner
{
require(verifiySignature(userOperation), "Invalid Signature");
(bool success, ) = userOperation.to.call{ // la funcion call se encarga de ejecutar la transaccion en la red
gas: userOperation.gasLimit,
value: userOperation.value
}(userOperation.data);
require(success, "Transaction failed");
}
}

Estas pruebas están diseñadas para verificar la funcionalidad del contrato HashOperationAndExecute. A continuación, te explico brevemente cada parte de las pruebas:

Explicación Breve:

  1. Configuración Inicial:
  • Importaciones: Se utilizan las bibliotecas chai, mocha, y ethers para escribir y ejecutar las pruebas.
  • Estructura UserOperationStruct: Define un objeto con valores por defecto para la estructura UserOperation que será utilizada en las pruebas.
  • Función deployHashOperationAndExecute: Despliega el contrato HashOperationAndExecute en la blockchain local utilizando el primer firmante como el propietario del contrato.

2. Prueba Should return the right hash:

  • Propósito: Verifica que el contrato pueda calcular el hash correcto de una operación de usuario, y que la transacción se ejecute exitosamente cuando se usa una firma válida.
  • Pasos:
  • Asigna valores al objeto UserOperationStruct.
  • Calcula el hash de la operación de usuario.
  • Envía Ether al contrato.
  • Firma el hash con el firmante correcto.
  • Verifica la firma y ejecuta la transacción.
  • Verifica que la transacción se haya ejecutado con éxito (estado status == 1).

3. Prueba Should return the invalid signature:

  • Propósito: Verifica que la transacción falle cuando se utiliza una firma inválida.
  • Pasos:
  • Asigna valores al objeto UserOperationStruct.
  • Calcula el hash de la operación de usuario.
  • Envía Ether al contrato.
  • Firma el hash con un firmante incorrecto.
  • Intenta verificar y ejecutar la transacción.
  • Captura el error y verifica que el mensaje de error sea 'Invalid Signature', lo que indica que la firma fue inválida y la transacción fue revertida.

Resumen:

Estas pruebas aseguran que el contrato HashOperationAndExecute funcione correctamente, validando que las transacciones solo se ejecuten si la firma es válida, y que las transacciones se reviertan con un mensaje de error adecuado cuando la firma no sea válida.

import { expect } from "chai";
import { describe, it } from 'mocha';
import { arrayify } from "@ethersproject/bytes";
import hre, { ethers } from "hardhat";
import { HashOperationAndExecute, } from "../typechain-types";
import { UserOperationStruct } from "../typechain-types/contracts/PaymentMaster/HashOperation.sol/HashOperationAndExecute";
// Default values for the UserOperationStruct
const defaultHashOperation: UserOperationStruct = {
gasLimit: 1000000n,
gasPrice: 1000000n,
nonce: 1,
to: '0x',
data: '0x',
signature: '0x',
value: 0n
}
// despliega el contrato y retorna la instancia
async function deployHashOperationAndExecute(): Promise<HashOperationAndExecute> {
const signers = await ethers.getSigners();
const HashOperation = hre.ethers.getContractFactory("HashOperationAndExecute");
const hashOperation = (await HashOperation).deploy(signers[0].address);
(await hashOperation).waitForDeployment();

return hashOperation;
}


describe("HashOperation", function () {
let hashOperation: HashOperationAndExecute
// despliega el contrato antes de correr los tests
before(async function () {
hashOperation = await deployHashOperationAndExecute();
});

it("Should return the right hash", async function () {
// asigna valores al UserOperationStruct
const signers = await ethers.getSigners();
defaultHashOperation.nonce = 1;
defaultHashOperation.to = signers[1].address;
defaultHashOperation.value = ethers.parseEther('1');
// obtiene el hash
const hash = await hashOperation.hashOperation(defaultHashOperation);
// envia ether al contrato
await signers[0].sendTransaction({
to: await hashOperation.getAddress(),
value: ethers.parseEther('5')
});
// el mensaje es firmado
const signature = await signers[0].signMessage(arrayify(hash));
defaultHashOperation.signature = signature;
// se verifica y ejecuta la transaccion
const exe = await hashOperation.connect(signers[0]).VerifyAndExecute(defaultHashOperation);
const tx = await exe.wait();

expect(tx?.status == 1).to.be.true;
});

it("Should return the invalid signature", async function () {

const signers = await ethers.getSigners();
defaultHashOperation.nonce = 1;
defaultHashOperation.to = signers[1].address;
defaultHashOperation.value = ethers.parseEther('1');
const hash = await hashOperation.hashOperation(defaultHashOperation);
await signers[0].sendTransaction({
to: await hashOperation.getAddress(),
value: ethers.parseEther('5')
});
// Sign the hash with the wrong signer
const signature = await signers[1].signMessage(arrayify(hash));
defaultHashOperation.signature = signature;
try {
const exe = await hashOperation.connect(signers[0]).VerifyAndExecute(defaultHashOperation);
await exe.wait();

} catch (error : any) {
expect(error.message).to.be.equal('Error: VM Exception while processing transaction: reverted with reason string \'Invalid Signature\'');
}
});

});

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

No responses yet

Write a response