Aller au contenu
ecdsa.com

EIP-191 vs EIP-712 : personal_sign ou données typées

Tous deux empêchent qu’une signature Ethereum serve aussi de transaction. EIP-191 signe un message préfixé ; EIP-712 signe des champs typés liés à une application, à une chaîne et à un contrat.

EIP-191 vs EIP-712, c’est le choix entre les deux formats de signature qu’Ethereum propose pour des données qui ne sont pas des transactions. EIP-191 (2016) définit un préfixe 0x19 qui ne peut jamais commencer l’encodage d’une transaction valide, suivi d’un octet de version qui sélectionne le format ; la version 0x45 est personal_sign, utilisée pour du texte lisible par un humain. EIP-712 (2017) est la version 0x01 : elle hache des données typées et structurées avec un séparateur de domaine qui nomme l’application, la chaîne et le contrat vérificateur. EIP-712 est donc un format particulier d’EIP-191, et non un concurrent de l’ensemble de la norme.

Vue d’ensemble

EIP-191 personal_sign (version 0x45)Données typées EIP-712 (version 0x01)
StatutFinal, date de création 2016-01-20Final, date de création 2017-09-12
Méthode RPC du portefeuillepersonal_signeth_signTypedData_v4
Octets hachés avec Keccak-2560x19 "Ethereum Signed Message:\n" longueur message0x19 0x01 domainSeparator hashStruct(message)
Ce qu’affiche le portefeuilleLe texte, ou de l’hexadécimal brut si le message est binaireLes noms et les valeurs des champs, plus le domaine (nom, chaîne, contrat)
Protection contre le rejeuAucune protection intégrée ; le texte du message doit porter le nonce, le domaine et la chaîneSéparateur de domaine avec name, version, chainId, verifyingContract, salt
Vérification côté contratReconstruire le message préfixé, puis ecrecoverRecalculer les condensats de type et de structure, puis ecrecover
Usages typiquesSign-In with Ethereum (EIP-4361), preuve de propriété d’une adressePermit (EIP-2612), Permit2, transactions Safe, ordres sur les plateformes d’échange, votes de gouvernance
SignatureECDSA secp256k1 de 65 octets r, s, vIdentique
Principaux piègesCondensats hexadécimaux signés à l’aveugle ; texte libre ambiguApprobations d’apparence anodine ; appareils qui n’affichent qu’un condensat

Ce que chacun hache

EIP-191 version 0x45 ajoute en tête "\x19Ethereum Signed Message:\n" et la longueur du message en ASCII décimal, puis hache le tout avec Keccak-256. Le préfixe garantit que les octets signés ne peuvent pas être une transaction encodée en RLP : un site ne peut donc pas amener un portefeuille à signer un transfert en le faisant passer pour un message. Il ne fait rien d’autre : que la signature signifie « se connecter à example.com » ou « approuver des dépenses illimitées » dépend entièrement de la façon dont le destinataire interprète le texte.

EIP-712 encode chaque structure comme le condensat de sa chaîne de type, suivi de mots de 32 octets pour ses champs : les chaînes et les tableaux d’octets sont hachés, les adresses et les entiers reçoivent un remplissage à gauche, les structures imbriquées sont remplacées par leur propre condensat. Le domaine est encodé de la même façon, comme une structure EIP712Domain. Le condensat final est keccak256(0x19 0x01 domainSeparator hashStruct(message)).

Le script ci-dessous calcule les deux avec @noble/curves 2.3.0 et @noble/hashes 2.3.0 sous Node.js 26, à partir du message « Ether Mail » et de la clé de signature de l’Example.js de référence d’EIP-712. Cette clé, keccak256("cow"), est publiée dans la spécification comme vecteur de test : n’importe qui peut la dériver, et son adresse ne doit donc jamais détenir de fonds.

Condensats EIP-191 et EIP-712, vérifiés par rapport à la référence d’EIP-712
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { keccak_256 } from "@noble/hashes/sha3.js";
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";

const hex = (b) => "0x" + bytesToHex(b);
// Test key published in the EIP-712 reference example (assets/eip-712/Example.js): keccak256("cow").
// It is public, so never send funds to its address.
const secretKey = keccak_256(utf8ToBytes("cow"));
const signer = hex(keccak_256(secp256k1.getPublicKey(secretKey, false).subarray(1)).subarray(12));
const sign = (digest) => {
  const sig = secp256k1.sign(digest, secretKey, { prehash: false, format: "recovered" });
  return { v: sig[0] + 27, r: hex(sig.subarray(1, 33)), s: hex(sig.subarray(33)) };
};

// EIP-191 version 0x45: personal_sign
const text = utf8ToBytes("hello");
const personalDigest = keccak_256(concatBytes(utf8ToBytes(`\x19Ethereum Signed Message:\n${text.length}`), text));
console.log("EIP-191 personal_sign digest:", hex(personalDigest));

// EIP-712: typed structured data (the "Ether Mail" example from the EIP)
const types = {
  EIP712Domain: [["name", "string"], ["version", "string"], ["chainId", "uint256"], ["verifyingContract", "address"]],
  Person: [["name", "string"], ["wallet", "address"]],
  Mail: [["from", "Person"], ["to", "Person"], ["contents", "string"]],
};
const encodeType = (primary) => {
  const deps = new Set();
  const walk = (t) => types[t].forEach(([, ft]) => { if (types[ft] && !deps.has(ft)) { deps.add(ft); walk(ft); } });
  walk(primary);
  return [primary, ...[...deps].sort()].map((t) => `${t}(${types[t].map(([n, ft]) => `${ft} ${n}`).join(",")})`).join("");
};
const encodeValue = (ft, v) =>
  types[ft] ? hashStruct(ft, v)
  : ft === "string" ? keccak_256(utf8ToBytes(v))
  : ft === "address" ? concatBytes(new Uint8Array(12), hexToBytes(v.slice(2)))
  : hexToBytes(BigInt(v).toString(16).padStart(64, "0")); // uint256
const hashStruct = (t, v) =>
  keccak_256(concatBytes(keccak_256(utf8ToBytes(encodeType(t))), ...types[t].map(([n, ft]) => encodeValue(ft, v[n]))));

const domain = { name: "Ether Mail", version: "1", chainId: 1, verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" };
const mail = {
  from: { name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" },
  to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" },
  contents: "Hello, Bob!",
};
const typedDigest = (d) => keccak_256(concatBytes(new Uint8Array([0x19, 0x01]), hashStruct("EIP712Domain", d), hashStruct("Mail", mail)));

console.log("encodeType(Mail):", encodeType("Mail"));
console.log("domainSeparator:", hex(hashStruct("EIP712Domain", domain)));
console.log("hashStruct(Mail):", hex(hashStruct("Mail", mail)));
console.log("EIP-712 digest:", hex(typedDigest(domain)));
console.log("signer:", signer, sign(typedDigest(domain)));
console.log("same mail, chainId 10:", hex(typedDigest({ ...domain, chainId: 10 })));
Sortie
EIP-191 personal_sign digest: 0x50b2c43fd39106bafbba0da34fc430e1f91e3c96ea2acee2bc34119f92b37750
encodeType(Mail): Mail(Person from,Person to,string contents)Person(string name,address wallet)
domainSeparator: 0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
hashStruct(Mail): 0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
EIP-712 digest: 0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer: 0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826 {
  v: 28,
  r: '0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d',
  s: '0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562'
}
same mail, chainId 10: 0x2752db86097e9749484b4645855d9ff3d1b6109c89ed55a9d7f9021dbc77bcf9

La chaîne de type, le séparateur de domaine, le condensat de la structure, le condensat final, l’adresse du signataire et v, r, s sont exactement les valeurs que vérifie l’Example.js de référence d’EIP-712, et la signature est déterministe (RFC 6979) : vous pouvez donc la reproduire. Changer uniquement le chainId produit un condensat différent, et c’est ce qui empêche le rejeu d’une chaîne à l’autre. L’inspecteur EIP-712 affiche les mêmes condensats intermédiaires pour n’importe quelle charge utile de données typées.

Ce que voient réellement les utilisateurs

Le principal avantage d’EIP-712 tient à l’affichage : un portefeuille peut montrer « spender », « value » et « deadline » au lieu d’une chaîne opaque. Cela n’aide que si l’appareil sait afficher les champs et si l’utilisateur les lit. La plupart des campagnes d’hameçonnage qui siphonnent des fonds demandent aujourd’hui des signatures EIP-712 parfaitement valides, comme une approbation permit (EIP-2612) ou Permit2 qui accorde à un spender (l’adresse autorisée à dépenser) un nombre illimité de jetons, sans transaction ni gaz : l’utilisateur ne voit donc aucun avertissement sur la chaîne. En février 2025, trois signataires de Bybit ont approuvé une transaction Safe malveillante sur des portefeuilles matériels qui ne pouvaient en afficher que le hash ; la transaction a remplacé l’implémentation du portefeuille, et environ 1,5 milliard de dollars d’ETH ont été siphonnés. Recalculer de façon indépendante le hash d’une transaction Safe, comme le fait l’outil de hash de transaction Safe, permet aux signataires de vérifier ce qu’un appareil affiche en hexadécimal.

personal_sign présente la faiblesse inverse : les portefeuilles affichent fidèlement le texte, mais un site peut demander aux utilisateurs de signer une chaîne hexadécimale de 32 octets, qui ne révèle rien. Pour cette raison, la signature de condensats bruts sans aucun préfixe (eth_sign) a été désactivée par défaut dans MetaMask en 2023, puis supprimée en 2024.

Vérifier les signatures

Pour les comptes détenus en externe, les deux formats se vérifient en recalculant le condensat et en récupérant le signataire avec ecrecover (ou l’équivalent d’une bibliothèque), puis en comparant l’adresse ; rejetez les signatures high-s pour éviter la malléabilité. Les portefeuilles de contrat intelligent (Safe, comptes ERC-4337) ne peuvent pas produire de signatures ECDSA qui leur soient propres : les vérificateurs doivent donc appeler ERC-1271 isValidSignature(hash, signature) et accepter la valeur magique 0x1626ba7e ; ERC-6492 étend ce mécanisme aux portefeuilles pas encore déployés. Le vérificateur de signatures Ethereum et le vérificateur SIWE gèrent les deux cas.

Quand choisir l’un ou l’autre

  • Connexion et preuve de propriété d’une adresse : EIP-191 avec un message EIP-4361 (SIWE) qui inclut le domaine, l’URI, l’ID de chaîne, le nonce et l’expiration.
  • Approbations de jetons sans transaction : permit EIP-712 ou Permit2, avec des échéances courtes.
  • Ordres, votes, méta-transactions, attestations : EIP-712 avec un domaine qui inclut chainId et verifyingContract.
  • Transactions multisig : EIP-712, comme le fait Safe, avec en plus une vérification indépendante du hash sur un second appareil.
  • Texte libre qu’une personne doit lire : EIP-191.
  • Jamais : personal_sign sur un condensat, ni des domaines EIP-712 sans ID de chaîne.

Notes de migration

  1. Migration d’une autorisation hors chaîne de personal_sign vers EIP-712 : définissez la structure, incluez un champ nonce et un champ d’échéance, et ne continuez à accepter l’ancien format que jusqu’à l’expiration des signatures en circulation.
  2. Versionnez le domaine : incrémentez version quand la sémantique des champs change, afin que d’anciennes signatures ne puissent pas être interprétées selon de nouvelles règles.
  3. Vérifiez avec la bibliothèque qui vous sert à signer, et testez avec les vecteurs de référence d’EIP-712 ci-dessus.
  4. Prenez en charge les portefeuilles de contrat intelligent via ERC-1271 et ERC-6492 au lieu de supposer que ecrecover suffit.
  5. Prévoyez révocation et expiration : donnez aux utilisateurs un moyen de révoquer les permits et utilisez des échéances courtes ; les signatures sans expiration restent utilisables indéfiniment.

Références

Tous les comparatifs