WEB3DEV

Cover image for Introdução à Criptografia e Assinaturas no Ethereum
Marcelo Creimer
Marcelo Creimer

Posted on

Introdução à Criptografia e Assinaturas no Ethereum

Tradução de Immunefi feita por mim, artigo original disponível aqui

Todo mundo que já lidou alguma vez com um sistema de blockchain como o Ethereum, sabe do que consiste um blockchain, como blocos, transações e contas. Mas nós não pensamos frequentemente sobre o básico dos sistemas de blockchain, assim como também não pensamos frequentemente como nossos órgãos funcionam. Como os órgãos precisam de sangue e oxigênio para funcionar, o blockchain precisa de criptografia para funcionar devidamente. Sem ela, o sistema iria desmoronar, e você não seria capaz de reclamar sua propriedade daquele novo e reluzente NFT que você acabou de cunhar.

Era inevitável que cedo ou tarde nós precisássemos falar sobre criptografia e assinatura digital. Mas conte conosco, nós iremos fazer isso da maneira mais digerível possível.

Criptografia de Chave Pública

Dois dos principais propósitos da criptografia são provar conhecimento de um segredo sem revelar este segredo, e provar a autenticidade de dados (assinatura digital). A criptografia é usada extensivamente dentro do Ethereum, e um lugar que usuários têm contato com ela é via contas Ethereum.

A prova de propriedade em Externally Owned Accounts (Contas de propriedade externa, ou EOAs na sigla em inglês) é estabelecida através de chaves privadas e assinaturas digitais. As chaves privadas são usadas quase em todo lugar dentro do Ethereum durante as interações do usuário, e o endereço Ethereum de um EOA é derivado da chave privada. Em outras palavras, o endereço Ethereum é os últimos 20 bytes do hash da chave pública, controlando a conta com 0x anexado na sua frente.

Para provar que você é o verdadeiro proprietário de um EOA, você precisa assinar a mensagem com a chave privada correspondente. Isto significa que somente você tem acesso aos fundos da sua conta. Quando você faz uma transação enviando 1 Ether para um contrato para cunhar um novo NFT, nos bastidores, o Ethereum verifica a assinatura digital que você criou (usando a chave privada) contra o correspondente hash da chave pública (o endereço).

É similar a ir a um banco e pedir uma retirada de R$1.000 da conta do João da Silva. O banco precisa verificar primeiro se a pessoa que pediu a retirada é mesmo o João da Silva, e não outra pessoa. Criptografia de chave pública é baseada em funções matemáticas que permitem um único par de chaves pública / privada. Estes pares de chaves têm propriedades especiais, como facilidade de criação, mas é extremamente difícil (praticamente impossível) criar uma chave privada a partir da sua chave pública. Ter uma chave privada facilita criar a chave pública, mas só de se conhecer a chave pública, nós não conseguimos saber qual chave privada foi usada para criar a chave pública.

Uma das maneiras matemáticas mais comuns para computar chaves seguras é usar números primos. Se nós te dermos o número 6747437 e te disser que ele foi computado usando dois números primos, seria extremamente difícil você adivinhar quais dois foram usados. Calcular o resultado da multiplicação de dois números primos é fácil, mas fazer o inverso é difícil. Claro, nós usamos um dos menores números primos da Wikipedia, mas se nós fossemos usar dois números primos grandes, encontrá-los é difícil, mesmo para um computador.

Como aprendemos, criptografia de chave pública (também conhecida como encriptação assimétrica) é um método criptográfico que usa um sistema de par de chaves. A primeira chave, chamada de chave privada, assina a mensagem. A outra chave, chamada de chave pública, verifica a assinatura. Quando assinamos qualquer mensagem, sendo uma transação no Ethereum ou qualquer forma de dado, nós criamos uma assinatura digital. Isto é feito criando o hash da mensagem e rodando o algoritmo ECDSA para combinar o hash com a chave privada, produzindo uma assinatura. Fazendo isto, quaisquer mudanças na mensagem irão resultar em um valor diferente de hash.

Como podemos ler do livro Mastering Ethereum, uma assinatura digital pode ser criada para assinar qualquer mensagem. Para transações Ethereum, os detalhes da própria transação são usados na mensagem. A matemática da criptografia — nesse caso, criptografia de curva elíptica — fornece uma maneira da mensagem (isto é, os detalhes da transação) a ser combinada com a chave privada para criar um código que só pode ser produzido com o conhecimento da sua chave privada. Este código é chamado de assinatura digital.

Acima temos uma outra explicação de assinaturas digitais, mas no contexto das transações Ethereum. Esta explicação nos introduz a outro assunto muito importante - criptografia de curva elíptica.

Assinaturas Digitais

Os smart contracts no Ethereum têm acesso ao algoritmo built-in (embutido) de verificação de assinatura ECDSA através do método ecrecover. Esta função permite você verificar a integridade do dado assinado pelo hash e recuperar o assinante da chave pública.

Image description

Ela usa V,R,S do ECDSA e o hash da mensagem. Lembre-se, assinaturas digitais não precisam apenas se relacionar a transações. Com uma chave privada, nós podemos assinar qualquer dado arbitrário. E graças ao ecrecover, nós temos uma maneira de verificar assinaturas de dentro de smart contracts! Isto abre as portas para um mundo de oportunidades, e também de potenciais armadilhas. Vamos focar no lado positivo por enquanto.

Uma dessas oportunidades com verificação de assinatura em smart contracts no Ethereum é uma maneira de criar meta-transações. Uma meta-transação é um método de separar a pessoa que paga pelo gas da transação, da pessoa que se beneficia da execução da transação. Um usuário assina a interna, meta-transação e então a envia para um operador ou algo similar — nenhum gas e interação com blockchain é necessária. O operador pega a meta-transação assinada e a submete ao blockchain, pagando, ele mesmo, as taxas da transação externa e comum.

Um exemplo do acima seria o ERC20-Permit (Aprovação do ERC20), padronizado como ERC2612. Um estranho problema com o padrão ERC20 é que ele usa um processo de dois passos para garantir que um smart contract use os fundos de um usuário. Primeiro, nós precisamos criar uma transação approve(). Nós precisamos esperar pela transação ser minerada, e depois disso, nós chamamos transferFrom() do contrato em si para fazer algumas operações. Um dos principais exemplos com este fluxo de trabalho é o uso de DEX (Corretora descentralizada).

Quando nós queremos trocar USDC por WETH, nós primeiro precisamos chamar approve() no contrato do USDC para deixar a DEX transacionar nosso USDC. Então, para fazer a real troca, a DEX irá chamar transferFrom() nos bastidores na segunda transação. Nós precisamos de duas transações para realizar uma simples ação.

Com a função de aprovação do ERC20-Permit, você apenas assina a meta-transação com a sua carteira, e a outra parte (como uma DEX ou outro aplicativo) pode submetê-la ao blockchain em seu nome. Isto te economiza gas e elimina a necessidade de duas transações, como no caso anterior. A função de aprovação é desenhada para fazer a experiência do usuário menos estressante e habilitar transações sem custo de gas.

Se você quiser ler um tutorial sobre a aprovação ERC20-Permit, nós recomendamos ler post do blog no tópico.

Agora nós estamos chegando ao primeiro problema comum: uma assinatura válida pode ser usada diversas vezes em outros lugares onde ela não deveria ser usada.

Ataques Replay

Imagine um cenário onde nós temos uma função que transfere fundos, mas somente quando uma assinatura válida é fornecida.

Image description

À primeira vista, o código parece bom. Nós checamos o endereço da assinatura ECDSA fornecendo os valores v,r,s. Nós comparamos o endereço retornado com o endereço do proprietário, e se ele é o proprietário, nós procedemos com a transferência de fundos.

O problema com o código acima está na mensagem que é assinada pelo proprietário usando o algoritmo ECDSA. A mensagem contém somente o endereço do recebedor e a quantidade a ser liberada. Não há nada na mensagem que poderia evitar as mesmas assinaturas de serem usadas diversas vezes.

Imagine um cenário onde o proprietário envia 1 Ether para Alice usando transferFunds. Alice poderia reutilizar a mesma assinatura (V,R,S) e enviar para ela mesma um outro 1 Ether, ou ainda mesmo repetir isto diversas vezes até drenar o contrato.

Para evitar o ataque do replay da assinatura, nós podemos armazenar a assinatura que nós usamos no mapping executed. Desse modo, quando alguém quiser repetir nossa assinatura, ele iria falhar, já que podemos checar se essa assinatura já foi utilizada, simplesmente checando o mapping.

Combinar os resultados acima no código de uma função, seria algo como:

Image description

Se você quiser checar o exemplo do código completo, nós recomendamos que você cheque o capítulo signature replay attack do Solidity-by-example.

Ainda há alguns problemas com o código acima. Ele não segue as boas práticas recomendadas para verificação de assinatura, especialmente o valor s.

Maleabilidade de Assinatura

Dentro do Solidity, uma assinatura ECDSA é representada pelos seus valores r, s e v. A precisa relação matemática entre a chave pública, o hash da mensagem, r, s, e v pode ser checada para garantir que somente a pessoa que conhece a correspondente chave privada calcule r, s, e v. Entretanto, por causa da estrutura simétrica das curvas elípticas, para cada conjunto de r, s, e v, há um outro conjunto fácil de computar conjunto de r, s, e v que também tem a mesma precisa relação matemática. Isto resulta em DUAS válidas assinaturas e viola a ideia de que somente a pessoa com a chave privada pode computar uma assinatura.

Felizmente, é fácil detectar a dupla assinatura.

Nós estamos apenas interessados em uma, então nós precisamos de uma maneira de mostrar qual das duas assinaturas está sendo exibida. Como dissemos, a curva elíptica é simétrica. O v simplesmente indica em qual lado do espelho a assinatura está. O v pode ser *27 *(0x1b) ou *28 *(0x1c). Mais informações sobre v podem ser encontradas no Ethereum Yellow Paper Appendix F.

Image description

https://github.com/ethereumbook/ethereumbook/blob/develop/images/simple_elliptic_curve.png

Escolher a “metade” apropriada é importante quando se tratar de um ponto s. Conforme visto acima, uma Curva Elíptica é simétrica no eixo-X, significando que dois pontos podem existir com o mesmo valor X. Nós podemos cuidadosamente ajustar s para produzir uma assinatura válida para o mesmo r no outro lado do eixo-X.

O sentido por trás de tudo isso é podermos inverter uma assinatura válida para obter outra assinatura válida, a qual ainda será válida e basicamente fará o replay da assinatura! Há um meio de evitar isso e o primeiro maior hard-fork do Ethereum introduziu uma solução para isso: EIP-2.

O EIP-2 introduziu limites no valor do s para evitar a maleabilidade de assinatura, considerando somente os níveis mais baixos de s como válidos. Restringindo a faixa válida na metade, o EIP-2 efetivamente remove metade dos pontos do grupo, garantindo que há no máximo um ponto válido em cada coordenada x.

Apesar do EIP-2 ter sido introduzido na EVM, ele não afetou os contratos pré-compilados ecrecover. Portanto, sempre que estivermos usando ecrecover, ainda estamos sujeitos à maleabilidade de assinatura. Mas não se preocupe, pois a OpenZeppelin criou uma biblioteca apropriada (ECDSA.sol) que resolve este problema.

O truque é simples: nós restringimos o valor de s a estar na faixa final.

Image description

Nonces

Uma outra maneira de combater a maleabilidade de assinatura e o replay é o uso de nonce ao nível da aplicação. “Nonce” é a abreviação criptográfica para “number used once” (número usado só uma vez). Nós podemos usar um nonce para cada assinatura e armazenar o próximo nonce dentro do contrato.

Image description

Isto cobre os problemas mais comuns com assinaturas. Mas espere, há mais. Se dois contratos usam o mesmo encoding (codificação) de mensagens (talvez haja um token não-fungível que usa keccak256(abi.encodePacked(_to, _tokenId, nonce[_from]))), uma assinatura usada por um contrato pode também ser válida para o outro. Então nós temos que ir um passo além. Nós temos que fazer o hash de algumas informações de identificação sobre o contrato dentro da nossa mensagem para garantir que outros contratos não possam usar esta assinatura.

Você pode se perguntar (ou a nós): há algum padrão a seguir ou eu deveria, como desenvolvedor Solidity, implementar tudo a mão? Não, e graças ao EIP e aos padrões que ele introduziu ao ecossistema.

Para ajudar a padronizar o uso de assinatura no Ethereum, o EIP-712 foi introduzido e atualmente é largamente utilizado e considerado como um padrão que ajuda os desenvolvedores a evitar os problemas de segurança mais comuns quando lidando com assinaturas.

EIP-712

O principal objetivo do EIP-712 é garantir que os usuários entendam exatamente o que eles estão assinando, para qual endereço de contrato e rede, e que cada assinatura só possa ser usada pelo devido contrato. Isto é realizado assinando hashes dos dados de configuração necessários(endereço, id da rede, versão, e tipos de dado), assim como o dado em si.

O EIP-712 é um padrão para hashing e assinaturas para dados estruturados, ao invés de apenas strings. Ele inclui um:

  • framework teórico para a correção de funções de codificação (encoding),
  • especificação de dado estruturado similar e compatível com os structs do Solidity,
  • algoritmo seguro de hashing para instâncias destas estruturas,
  • inclusão segura destas instâncias no conjunto de mensagens passíveis de assinatura,
  • um extensivo mecanismo para separação de domínio,
  • nova chamada RPC eth_signTypedData, e
  • uma implementação otimizada do algoritmo de hashing na EVM.

O padrão previamente mencionado, ERC20-Permit, também confia no EIP-712. Nós iremos focar na implementação do OpenZeppelin para explicar como o EIP-712 funciona. O código abaixo contém o mais importante pedaço de código de ERC20Permit.sol e EIP712.sol.

Image description

Separador de Domínio: _domainSeparatorV4 Isto garante que uma assinatura seja utilizada apenas na devida rede para o nosso endereço de contrato de token fornecido. Depois do fork do Ethereum Classic, que continuou a utilizar o id de rede 1, a identificação de rede (chain id) foi introduzida para identificar precisamente uma rede. Por ter ocorrido contratos deployed no mesmo endereço em ambas redes depois do hardfork, o chain id precisa ser incluído para distingui-las.

Struct Hash: bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));

Este structHashgarante que a assinatura só pode ser usada para finalidades específicas, ou seja, somente para a função permit(), que pode ser chamada apenas pelo proprietário, com valores aprovados especificamente ao gastador. Ele também adiciona verificações em relação ao prazo e verificações de nonce para que não possa ser feito replay dele.

Final EIP712 Hash: bytes32 hash = _hashTypedDataV4(structHash);

Isto usa o padrão de dados assinados do EIP-191 para definir um número de versão e dados específicos da versão. 0x19 como configuração do prefixo, e 0x01 como byte da versão para indicar que é uma assinatura EIP-712. Mais tarde, nós embrulhamos junto o separador de domínio e nossa estrutura de hash. O hash que nós obtemos é a nossa mensagem final já em hash. Ainda mais tarde, nós podemos continuar a extrair endereços do assinante. Note o uso da biblioteca ECDSA para contabilizar todos os tipos de problemas com assinaturas, como maleabilidade de assinatura.

Resumo

Nós esperamos que este artigo tenha te dado um melhor entendimento sobre assinaturas digitais no Ethereum, e como gerenciá-las efetivamente. A necessidade deste artigo veio do fato de que nós estamos vendo mais problemas com mau uso de assinatura em vários projetos. Com todo o conhecimento prévio, você deve ser capaz de validar o uso de assinatura no código e descobrir alguns problemas. Lembre-se de que isto é meramente uma breve visão geral do assunto; há muito mais a aprender.

Latest comments (0)