Перейти к содержимому
ecdsa.com

EIP-191 или EIP-712: personal_sign и типизированные данные

Оба формата не дают подписи Ethereum сойти за транзакцию. В EIP-191 подписывается сообщение с префиксом, в EIP-712 — типизированные поля, привязанные к приложению, сети и контракту.

Выбор между EIP-191 и EIP-712 — это выбор между двумя форматами Ethereum для подписи данных, отличных от транзакций. EIP-191 (2016) задаёт префикс 0x19, с которого никогда не может начинаться корректная кодировка транзакции, и байт версии, определяющий формат; версия 0x45 — это personal_sign для текста, понятного человеку. EIP-712 (2017) — это версия 0x01: типизированные структурированные данные хешируются вместе с разделителем домена (domain separator), в котором указаны приложение, сеть и проверяющий контракт. Таким образом, EIP-712 — один из конкретных форматов EIP-191, а не конкурент стандарта в целом.

Коротко о главном

EIP-191 personal_sign (версия 0x45)Типизированные данные EIP-712 (версия 0x01)
СтатусFinal, создан 20 янв. 2016 г.Final, создан 12 сент. 2017 г.
RPC-метод кошелькаpersonal_signeth_signTypedData_v4
Байты, которые хешируются Keccak-2560x19, "Ethereum Signed Message:\n", длина, сообщение0x19 0x01 domainSeparator hashStruct(message)
Что показывает кошелёкТекст или сырой hex, если сообщение двоичноеИмена и значения полей, а также домен (название, сеть, контракт)
Защита от повторного использованияВстроенной нет: nonce, домен и сеть должны быть в тексте сообщенияРазделитель домена с name, version, chainId, verifyingContract, salt
Проверка на стороне контрактаЗаново собрать сообщение с префиксом, затем ecrecoverПересчитать хеши типов и структур, затем ecrecover
Типичные примененияSign-In with Ethereum (EIP-4361), доказательство владения адресомPermit (EIP-2612), Permit2, транзакции Safe, биржевые заявки, голосования по управлению
ПодписьECDSA на secp256k1, 65 байт: r, s, vТакая же
Главные ловушкиХеши в hex, подписанные вслепую; двусмысленный произвольный текстБезобидные на вид одобрения; устройства, которые показывают только хеш

Что хеширует каждый из них

EIP-191 версии 0x45 добавляет в начало "\x19Ethereum Signed Message:\n" и длину сообщения десятичным числом в ASCII, а затем хеширует результат с помощью Keccak-256. Префикс гарантирует, что подписанные байты не могут оказаться транзакцией в кодировке RLP, поэтому сайт не сможет обманом заставить кошелёк подписать перевод средств, выдав его за сообщение. Больше префикс ничего не делает: означает ли подпись «войти на example.com» или «одобрить неограниченные траты», целиком зависит от того, как получатель истолкует текст.

EIP-712 кодирует каждую структуру как хеш её строки типа, за которым следуют 32-байтные слова для её полей: строки и байты хешируются, адреса и целые числа дополняются нулями слева, вложенные структуры заменяются собственными хешами. Домен кодируется так же, как структура EIP712Domain. Итоговый дайджест — keccak256(0x19 0x01 domainSeparator hashStruct(message)).

Скрипт ниже вычисляет оба дайджеста с помощью @noble/curves 2.3.0 и @noble/hashes 2.3.0 на Node.js 26, используя сообщение «Ether Mail» и ключ подписи из эталонного Example.js к EIP-712. Этот ключ, keccak256("cow"), опубликован в спецификации как тестовый вектор: вывести его может кто угодно, поэтому на его адресе никогда не должно быть средств.

Дайджесты EIP-191 и EIP-712 со сверкой по эталону 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 })));
Результат
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

Строка типа, разделитель домена, хеш структуры, дайджест, адрес подписанта и v, r, s в точности совпадают со значениями, которые проверяет эталонный Example.js к EIP-712, а подпись детерминированная (RFC 6979), поэтому её можно воспроизвести. Если изменить только chainId, получится другой дайджест — именно это и не даёт повторно использовать подпись в другой сети. Инспектор EIP-712 показывает те же промежуточные хеши для любой полезной нагрузки с типизированными данными.

Что на самом деле видят пользователи

Главное преимущество EIP-712 — отображение: кошелёк может показать «spender», «value» и «deadline» вместо непонятной строки. Это помогает, только если устройство умеет отображать поля, а пользователь их читает. Большинство фишинговых атак, опустошающих кошельки, сейчас запрашивают полностью корректные подписи EIP-712, например одобрение permit (EIP-2612) или Permit2, которое разрешает spender тратить неограниченное количество токенов, причём без транзакции и без газа, поэтому никакого предупреждения о транзакции в блокчейне пользователь не видит. В феврале 2025 года трое подписантов Bybit одобрили вредоносную транзакцию Safe на аппаратных кошельках, которые могли показать только её хеш; транзакция подменила реализацию кошелька, и с него вывели ETH примерно на 1,5 миллиарда долларов. Подписанты могут проверить то, что устройство показывает в hex, если независимо пересчитают хеш транзакции Safe, как это делает инструмент для расчёта хеша транзакции Safe.

У personal_sign слабость зеркальная: кошельки точно показывают текст, но сайт может попросить пользователя подписать 32-байтную hex-строку, которая ничего не говорит. По этой причине подпись сырого хеша без всякого префикса (eth_sign) в MetaMask отключили по умолчанию в 2023 году, а в 2024-м удалили.

Проверка подписей

Для внешних аккаунтов (externally owned accounts, EOA) подписи обоих форматов проверяют так: пересчитывают дайджест, восстанавливают подписанта с помощью ecrecover (или аналога из библиотеки) и сравнивают адрес; отклоняйте подписи с high-s, чтобы исключить пластичность. Кошельки на смарт-контрактах (Safe, аккаунты ERC-4337) не могут создавать собственные подписи ECDSA, поэтому проверяющие стороны должны вызывать ERC-1271 isValidSignature(hash, signature) и принимать магическое значение 0x1626ba7e; ERC-6492 распространяет этот подход на ещё не развёрнутые кошельки. Инструменты проверки подписей Ethereum и проверки SIWE учитывают оба случая.

Что когда выбирать

  • Вход и доказательство владения адресом: EIP-191 с сообщением EIP-4361 (SIWE), в котором есть домен, URI, chain ID, nonce и срок действия.
  • Одобрение токенов без транзакции: permit по EIP-712 или Permit2 с коротким сроком (deadline).
  • Заявки, голосования, метатранзакции, аттестации: EIP-712 с доменом, который включает chainId и verifyingContract.
  • Транзакции мультиподписи: EIP-712, как в Safe, плюс независимая проверка хеша на втором устройстве.
  • Произвольный текст, который должен прочитать человек: EIP-191.
  • Никогда: подпись хеша через personal_sign или домены EIP-712 без chain ID.

Заметки о миграции

  1. Перенос авторизации вне блокчейна с personal_sign на EIP-712: определите структуру, добавьте поля nonce и deadline и принимайте старый формат только до тех пор, пока не истечёт срок действия уже выданных подписей.
  2. Версионируйте домен: увеличивайте version, когда меняется смысл полей, чтобы старые подписи нельзя было истолковать по новым правилам.
  3. Проверяйте подписи той же библиотекой, которой подписываете, и тестируйте по эталонным векторам EIP-712, приведённым выше.
  4. Поддержите смарт-кошельки через ERC-1271 и ERC-6492, а не рассчитывайте только на ecrecover.
  5. Отзывайте и ограничивайте срок действия: дайте пользователям способ отзывать permit и задавайте короткие сроки; подписи без срока действия остаются пригодными бессрочно.

Источники

Все сравнения