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) | |
|---|---|---|
| Statut | Final, date de création 2016-01-20 | Final, date de création 2017-09-12 |
| Méthode RPC du portefeuille | personal_sign | eth_signTypedData_v4 |
| Octets hachés avec Keccak-256 | 0x19 "Ethereum Signed Message:\n" longueur message | 0x19 0x01 domainSeparator hashStruct(message) |
| Ce qu’affiche le portefeuille | Le texte, ou de l’hexadécimal brut si le message est binaire | Les noms et les valeurs des champs, plus le domaine (nom, chaîne, contrat) |
| Protection contre le rejeu | Aucune protection intégrée ; le texte du message doit porter le nonce, le domaine et la chaîne | Séparateur de domaine avec name, version, chainId, verifyingContract, salt |
| Vérification côté contrat | Reconstruire le message préfixé, puis ecrecover | Recalculer les condensats de type et de structure, puis ecrecover |
| Usages typiques | Sign-In with Ethereum (EIP-4361), preuve de propriété d’une adresse | Permit (EIP-2612), Permit2, transactions Safe, ordres sur les plateformes d’échange, votes de gouvernance |
| Signature | ECDSA secp256k1 de 65 octets r, s, v | Identique |
| Principaux pièges | Condensats hexadécimaux signés à l’aveugle ; texte libre ambigu | Approbations 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.
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 })));
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
chainIdetverifyingContract. - 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_signsur un condensat, ni des domaines EIP-712 sans ID de chaîne.
Notes de migration
- Migration d’une autorisation hors chaîne de
personal_signvers 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. - Versionnez le domaine : incrémentez
versionquand la sémantique des champs change, afin que d’anciennes signatures ne puissent pas être interprétées selon de nouvelles règles. - Vérifiez avec la bibliothèque qui vous sert à signer, et testez avec les vecteurs de référence d’EIP-712 ci-dessus.
- Prenez en charge les portefeuilles de contrat intelligent via ERC-1271 et ERC-6492 au lieu de supposer que
ecrecoversuffit. - 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
- ERC-191, norme pour les données signées
- EIP-712, hachage et signature de données structurées typées et Example.js de référence
- ERC-2612, extension permit
- ERC-4361, norme Sign-In with Ethereum
- ERC-1271, validation standard des signatures pour les contrats et ERC-6492
- MetaMask MIP-3, abandon d’eth_sign