Esse artigo é uma tradução de Maarten Zuidhoorn feita por Felipe Gueller. Você pode encontrar o artigo original aqui.
Assinar e verificar mensagens é uma parte importante da blockchain, mas como tudo isso funciona?
As assinaturas criptográficas são uma parte fundamental da blockchain. Elas são usadas para provar a propriedade de um endereço sem expor sua chave privada. Isto é principalmente usado para assinar transações, mas pode ser também utilizado para assinar mensagens arbitrárias. Neste artigo você encontrará uma explicação técnica de como essas assinaturas funcionam, no contexto do Ethereum.
Isenção de responsabilidade: a criptografia é difícil. Por favor, não use nada deste artigo como uma instrução inicial para a implementação das suas próprias funções criptográficas. Apesar de que uma pesquisa extensa tenha sido feita, as informações disponibilizadas aqui podem ser imprecisas. Este artigo é apenas de propósito educacional.
O que é uma assinatura criptográfica?
Quando nós falamos sobre assinaturas na criptografia, falamos sobre algum tipo de prova de propriedade, validade, integridade etc. Elas podem ser usadas para:
- Provar que você tem uma chave privada para um endereço (autenticação);
- Certificar-se de que uma mensagem (por exemplo, e-mail) não tenha sido adulterada;
- Verificar se a versão do MyCrypto que você baixou é legítima.
Isto é baseado em fórmulas matemáticas. Nós pegamos uma mensagem de entrada, uma chave privada e (normalmente) uma chave aleatória, e com isso nós obtemos um número como resultado, o qual é uma assinatura. Usando outra fórmula matemática, esse processo pode ser revertido de tal forma que a chave privada e a chave aleatória são desconhecidas, mas podem ser verificadas. Existem muitos algoritmos para isso, como RSA e AES, mas o Ethereum (e o Bitcoin) utilizam o Algoritmo de Assinatura Digital de Curva Elíptica, ou ECDSA. Observe que o ECDSA é apenas um algoritmo de assinatura. Diferente do RSA e AES, ele não pode ser aplicado para criptografia.
Usando a manipulação de pontos de uma curva elíptica, podemos derivar um valor para a chave privada, que não é reversível. Este é um modo pelo qual nós conseguimos criar assinaturas seguras e invioláveis. As funções que realizam o processo de derivação dos valores são chamadas de “Funções Alçapão (trapdoor functions)”:
A função alçapão é uma função que é simples de se calcular em uma direção, mas é difícil de se calcular em uma direção oposta (encontrando o seu inverso) sem uma informação especial, chamada de “alçapão”.
Assinatura e verificação usando ECDSA
Assinaturas ECDSA consistem em dois números (inteiros): r
e s
. O Ethereum também pode operar com uma variável adicional v
(identificador de recuperação). A assinatura pode ser anotada como { r, s, v }
.
Para criar uma assinatura, você precisa de uma mensagem para assinar e uma chave privada (d
2
) para assiná-la. O processo de assinatura “de forma simplificada” se parece com isso:
- Calcule um hash (
e
) da mensagem para assinar. - Gerar um valor aleatório seguro para
k
. - Calcular o ponto (
x1, y1
) em uma curva elíptica multiplicandok
com a constanteG
da curva elíptica. - Calcular o
r = x1 mod n
. Ser
for igual a 0, volte dois passos. - Calcular
s = k⁻¹(e + rdₐ) mod n
. Ses
for igual a 0, volte para o passo 2.
No Ethereum, o hash é geralmente calculado com Keccak256("\x19Ethereum Signed Message:\n32" + Keccak256(message))
. Isso garante que a assinatura não possa ser utilizada para fins fora do Ethereum.
Por conta de usarmos um valor aleatório para k
, a assinatura que nós obtemos é sempre diferente. Quando k
não é suficientemente aleatório ou quando o valor não é secreto, é possível gerar uma chave privada com base em duas assinaturas diferentes (“ataque de falhas (fault attack)”). Entretanto, quando você assina uma mensagem no MyCrypto, a saída é sempre a mesma, então como isso pode ser seguro? Estas assinaturas determinísticas utilizam o padrão RFC 6979 standard, o qual descreve como você pode gerar um valor seguro para k
baseado em uma chave privada e uma mensagem (ou hash).
A assinatura {r, s, v}
pode ser combinada em uma sequência de 65 bytes: 32 bytes para r
, 32 bytes para s
, e um byte para v
. Se nós a codificamos como uma string hexadecimal, resultaria em uma string de 130 caracteres, que é operada pela maioria das carteiras digitais e interfaces. Por exemplo, uma assinatura completa no MyCrypto se parece com isto:
{
"address": "0x76e01859d6cf4a8637350bdb81e3cef71e29b7c2",
"msg": "Hello world!",
"sig": "0x21fbf0696d5e0aa2ef41a2b4ffb623bcaf070461d61cf7251c74161f82fec3a4370854bc0a34b3ab487c1bc021cd318c734c51ae29374f2beb0e6f2dd49b4bf41c",
"version": "2"
}
Podemos utilizar isso na página “Verificar Mensagem” no MyCrypto, e será retornado que 0x76e01859d6cf4a8637350bdb81e3cef71e29b7c2
assinou esta mensagem.
Você pode se perguntar: por que incluir toda esta informação extra, como address
, msg
e version
? Você não pode apenas verificar a assinatura por si mesmo? Bem, na verdade não. Isso seria como assinar um contrato, e então se livrar de qualquer outra informação do contrato, e apenas manter a assinatura. Ao contrário das assinaturas de transações (iremos nos aprofundar nelas), uma assinatura de mensagem é apenas uma assinatura.
Para verificar uma mensagem, precisamos da mensagem original, do endereço da chave privada onde ela foi assinada, e de sua assinatura {r, s, v}
. O número de versão é apenas um número de versão arbitrário manuseado pelo MyCrypto. Versões realmente antigas do MyCrypto utilizavam a data e hora atual para adicionar uma mensagem, criar um hash disso e assinar seguindo os passos descritos acima. Isso foi posteriormente alterado para corresponder ao comportamento do método JSON-RPC personal_sign
, então a versão “2” foi introduzida.
O processo (de novo “simplificado”) para se recuperar uma chave pública se parece com isto:
- Calcular o hash (
e
) da mensagem a ser recuperada. - Calcular o ponto
R = (x₁, y₁)
na curva elíptica, onde x1 ér
parav = 27
, our + n
parav = 28
. - Calcular
u₁ = -zr⁻¹ mod n
eu₂ = sr⁻¹ mod n
. - Calcular o ponto
Qₐ = (xₐ, yₐ) = u₁ × G + u₂ × R
.
Qₐ
é ponto da chave pública para a chave privada com o qual o endereço foi assinado. Nós podemos derivar um endereço em cima dele e verificar se confere com o endereço fornecido. Se conferir, a assinatura é válida.
O identificador de recuperação (“v”)
v
é o último byte de uma assinatura, e é ou 27 (0x1b
) ou 28 (0x1c
). Este identificador é importante porque, como nós estamos trabalhando com curvas elípticas, existem diversos pontos na curva que podem ser calculados a partir de r
e s
somente. Isso resultaria em duas chaves públicas distintas (portanto, endereços), que podem ser recuperadas. O v
simplesmente indica qual desses pontos usar.
Na maioria das implementações, o valor de v é de 0 ou 1 internamente, mas o número 27 foi adicionado apenas de forma arbitrária para assinar mensagens Bitcoin, e o Ethereum acabou também realizando essa adaptação.
Desde o EIP-155, usamos o ID da chain para calcular o valor de v
. Isso previne ataques de repetição através de diferentes cadeias. Uma transação assinada para Ethereum não pode ser usada pelo Ethereum Clássico, e vice-versa. Atualmente, isso é usado apenas por assinaturas de transações, entretanto não é usada para assinar mensagens.
Transações assinadas
Até agora, falamos apenas de assinaturas no contexto de mensagens. Transações, assim como mensagens, são assinadas antes de serem enviadas. Para carteiras de hardware, como os dispositivos Ledger e Trezor, isso acontece no próprio dispositivo. Já para chaves privadas (ou arquivos keystorage, frases mnemônicas), isso ocorre diretamente no MyCrypto. Este processo utiliza um método que é muito semelhante ao de assinatura de mensagens, mas as transações são codificadas de maneira um pouco diferente.
Transações assinadas são codificadas em RLP, e consistem de todos os parâmetros da transação - nonce (number once), preço do gas, limite do gas, para, valor, dados - e da assinatura (v, r, s). Uma transação assinada se parece com isso:
0xf86c0a8502540be400825208944bbeeb066ed09b7aed07bf39eee0460dfa261520880de0b6b3a7640000801ca0f3ae52c1ef3300f44df0bcfd1341c232ed6134672b16e35699ae3f5fe2493379a023d23d2955a239dd6f61c4e8b2678d174356ff424eac53da53e17706c43ef871
Se inserirmos isso na página de transmissão de transação assinada do MyCrypto, veremos todos os parâmetros da transação:
O primeiro grupo de bytes de uma transação assinada contém os parâmetros de transação codificados em RLP, e o último grupo de bytes contém a assinatura {r, s, v}
. Nós podemos codificar uma transação assinada desta forma:
- Codifique os parâmetros de transação:
RLP(nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0)
. - Obtenha o _hash _ Keccak256 da transação não assinada codificada em RLP.
- Assine o hash com uma chave privada utilizando o algoritmo ECDSA, de acordo com as etapas descritas acima.
- Codifique a transação assinada:
RLP(nonce, gasPrice, gasLimit, to, value, data, v, r, s)
.
Para decodificar os dados de uma transação codificada em RLP, podemos obter os parâmetros brutos da transação e da assinatura novamente.
Note que o ID da chain é codificado no parâmetro de assinatura v
, então não incluímos o próprio ID da chain no final da transação assinada. Também não especificamos nenhum endereço “de destino”, pois o mesmo pode ser recuperado pela própria assinatura. Isso é usado internamente na rede Ethereum para verificar transações.
Padronização das mensagens assinadas
Existem várias propostas para definir uma estrutura padrão de mensagens assinadas. Atualmente, nenhuma destas propostas estão finalizadas, e o formato personal_sing
, primeiramente implementada por Geth, é ainda a mais utilizada. Mesmo assim, algumas destas propostas são muito interessantes.
Eu brevemente expliquei como as assinaturas são criadas atualmente:
"\x19Ethereum Signed Message:\n" + length(message) + message
A mensagem geralmente é criptografada com hash, portanto, o comprimento pode ser fixo em 32 bytes:
"\x19Ethereum Signed Message:\n32" + Keccak256(message)
A mensagem completa (incluindo o prefixo) é criptografada com hash novamente, e esses dados são assinados. Isso funciona bem para a prova de propriedade, mas pode ser um problema em outras situações. Por exemplo, se um usuário A
assinar uma mensagem e a enviar para o projeto X
, o usuário B
pode copiar essa mensagem assinada e enviá-la para o projeto Y
. Isso é chamado de ataque de repetição. EIP-191 e EIP-712 são algumas destas propostas que focam em resolver esse problema (e outros mais).
EIP-191: Padrão de dado assinado
O EIP-191 é uma proposta muito simples: define um número de versão e dados específicos da versão. O formato se parece com isso:
0x19 <1 byte version> <version specific data> <data to sign>
Os dados específicos da versão dependem (como o nome sugere) da versão que utilizamos. No tempo atual, o EIP-191 conta com três versões:
-
0x00
: Dados com “validador planejado”. No caso de um contrato, esse pode ser o endereço do contrato. -
0x01
: Dados estruturados, como definido da EIP-712. Isso será explicado mais adiante. -
0x45
: Mensagens regulares assinadas, como o comportamento atual dapersonal_sign
.
Se especificamos o validador desejado (por exemplo, um endereço de contrato), o contrato pode recalcular o hash com o seu próprio endereço. Enviar a mensagem assinada para uma instância diferente de um contrato não funcionará, pois o mesmo não poderá verificar a assinatura.
O prefixo fixo de 0x19
bytes foi escolhido para que a mensagem assinada não pudesse ser uma transação assinada codificada em RLP, visto que as transações codificadas em RLP nunca começam com 0x19
bytes.
EIP-712: Hashing e assinatura de dados estruturados do tipo Ethereum
Não se confunda com o ERC-721, o padrão de tokens não fungíveis, o EIP-712 é uma proposta para dados assinados “tipados”. Isso torna os dados assinados mais verificáveis, apresentando-os de forma mais legível para humanos.
O EIP-712 define um novo método para substituir personal_sign
: eth_signTypedData
. (com a última versão sendo eth_signTypedData_v4
). Para este método, nós temos que especificar todas as propriedades (por exemplo, to
, amount
, e nonce
) e seus respectivos tipos (por exemplo, address
e uint256
), como também as informações básicas da aplicação, chamada de domínio.
A definição para a mensagem como visto na imagem acima, são as seguintes:
{
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
{ name: 'salt', type: 'bytes32' }
],
Transaction: [
{ name: 'to', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'nonce', type: 'uint256' }
]
},
domain: {
name: 'MyCrypto',
version: '1.0.0',
chainId: 1,
verifyingContract: '0x098D8b363933D742476DDd594c4A5a5F1a62326a',
salt: '0x76e22a8ee58573472b9d7b73c41ee29160bc2759195434c1bc1201ae4769afd7'
},
primaryType: 'Transaction',
message: {
to: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
amount: 1000000,
nonce: 0
}
}
Como você pode ver, a message
é visível no próprio MetaMask, e podemos confirmar que o que estamos assinando é realmente o que queremos fazer. O EIP-712 implementa o EIP-191, portanto os dados começaram com 0x1901
: 0x19
como um prefixo definido, e 0x01
como byte de versão para indicar que é uma assinatura EIP-712.
Com Solidity nós podemos definir uma struct
para o tipo de Transaction
, e escrever uma função para fazer hash da transação:
struct Transaction {
address payable to;
uint256 amount;
uint256 nonce;
}
function hashTransaction(Transaction calldata transaction) public view returns (bytes32) {
return keccak256(
abi.encodePacked(
byte(0x19),
byte(0x01),
DOMAIN_SEPARATOR,
TRANSACTION_TYPE,
keccak256(
abi.encode(
transaction.to,
transaction.amount,
transaction.nonce
)
)
)
);
}
Os dados da transação acima se parece com isso, por exemplo:
0x1901fb502c9363785a728bf2d9a150ff634e6c6eda4a88196262e490b191d5067cceee82daae26b730caeb3f79c5c62cd998926589b40140538f456915af319370899015d824eda913cd3bfc2991811b955516332ff2ef14fe0da1b3bc4c0f424929
Isso consiste nos bytes do EIP-191, separador de domínio com hash, tipo de Transaction
com hash, e a entrada Transaction
. Esses dados são codificados com hash novamente e assinados. Então, nós podemos usar o ecrecover
na verificação da assinatura de em um smart contract
:
function verify (address signer, Transaction calldata transaction, bytes32 r, bytes32 s, uint8 v) public returns (bool) {
return signer == ecrecover(hashTransaction(transaction), v, r, s);
}
Ecrecover
será explicado em detalhes na próxima seção. Se você está procurando por uma biblioteca simples para trabalhar com EIP-712 em JavaScript ou TypeScript, por favor dê uma olhada nesta biblioteca:
https://github.com/Mrtenz/eip-712
Para obter uma explicação completa e detalhada de como implementar o EIP-712 em um smart contract, eu recomendo este artigo do MetaMask. Infelizmente, as especificações do EIP-712 ainda são um rascunho, e por enquanto, poucos aplicativos o suportam. Atualmente, Ledger e Trezor não têm suporte para EIP-712, o que pode impedir uma adoção mais ampla da especificação. No entanto, a Ledger disse que "em breve" lançariam uma atualização que adiciona suporte para o EIP-712.
Verificando assinaturas com smart contract
O que torna as assinaturas de mensagens mais interessante é que podemos usar smart contracts para verificar as assinaturas ECDSA. O Solidity tem uma função interna chamada de ecrecover
(que na verdade é um contrato pré compilado no endereço 0x01), que irá recuperar o endereço de uma chave privada com a qual a mensagem foi assinada.
A implementação de um contrato (muito) básico se parece com isso:
// SPDX-License-Identifier: MIT
pragma solidity 0.7.0;
contract SignatureVerifier {
/**
* @notice Recovers the address for an ECDSA signature and message hash, note that the hash is automatically prefixed with "\x19Ethereum Signed Message:\n32"
* @return address The address that was used to sign the message
*/
function recoverAddress (bytes32 hash, uint8 v, bytes32 r, bytes32 s) public pure returns (address) {
bytes memory prefix = "\x19Ethereum Signed Message:\n32";
bytes32 prefixedHash = keccak256(abi.encodePacked(prefix, hash));
return ecrecover(prefixedHash, v, r, s);
}
/**
* @notice Checks if the recovered address from an ECDSA signature is equal to the address `signer` provided.
* @return valid Whether the provided address matches with the signature
*/
function isValid (address signer, bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (bool) {
return recoverAddress(hash, v, r, s) == signer;
}
}
Este contrato não faz nada a mais que verificar assinaturas e seria bem inútil por si só, pois a verificação de assinaturas pode muito bem ser feita sem um smart contract.
O que torna algo assim útil é que o usuário tem uma forma confiável de fornecer certos comandos sem ter que enviar a transação. O usuário poderia, por exemplo, assinar uma mensagem dizendo, “por favor, envie 1 Ether do meu endereço para este endereço”. Um smart contract pode então verificar quem assinou essa mensagem e executar esse comando, utilizando um padrão como EIP-712 e/ou EIP-1077. A verificação de assinatura em smart contract pode ser utilizada em aplicações como:
- Multisig contracts (por exemplo, Gnosis Safe);
- Trocas descentralizadas;
- Transações de Meta ou transmissores de gas (por exemplo, Gas Station Network).
Mas, e se você já estiver usando uma carteira de smart contract da qual deseja assinar uma mensagem? Não podemos simplesmente acessar a chave privada por um contrato. O EIP-1271 propõe um padrão que permitiria smart contracts validarem assinaturas de outros smart contracts. A especificação é muito simples:
pragma solidity ^0.7.0;
contract ERC1271 {
bytes4 constant internal MAGICVALUE = 0x1626ba7e;
function isValidSignature(
bytes32 _hash,
bytes memory _signature
) public view returns (bytes4 magicValue);
}
Um contrato deve implementar a função isValidSignature
, a qual pode executar funções arbitrárias como a função acima. Se a assinatura for válida para o contrato de execução, a função retornará um MAGICVALUE
. Isso permite que qualquer contrato verifique a assinatura para um contrato que implementa o ERC-1271A. Internamente, o contrato que implementa o ERC-1271 pode fazer com que vários usuários assinem uma mensagem (neste caso, por exemplo, um contrato multisig), e armazenar um hash dentro de si mesmo. Em seguida, ele pode verificar se o hash fornecido à função isValidSignature
foi assinado internamente, e se a assinatura é válida para um dos donos do contrato.
Conclusão
As assinaturas são uma parte fundamental da blockchain e da descentralização. Não apenas para enviar transações, mas também para interagir com as mudanças descentralizadas, multisig contracts, e outros smart contracts. Ainda não há um padrão claro para assinaturas de mensagens, e a adoção adicional da especificação EIP-712 ajudaria o ecossistema a melhorar a experiência do usuário, bem como a ter um padrão para assinatura de mensagens.
Top comments (0)