本文へスキップ
ecdsa.com

EIP-191 と EIP-712 の比較|personal_sign か型付きデータ署名か

どちらも、Ethereum の署名がトランザクションとしても通用してしまうことを防ぎます。EIP-191 はプレフィックスを付けたメッセージに署名し、EIP-712 はアプリケーション、チェーン、コントラクトに結び付けられた型付きのフィールドに署名します。

EIP-191 と EIP-712 のどちらを使うかは、トランザクションではないデータに署名するための、Ethereum の 2 つの署名形式のどちらを選ぶかという問題です。EIP-191(2016年)は、有効なトランザクションのエンコーディングの先頭には決して現れない 0x19 というプレフィックスと、形式を選択するバージョンバイトを定めています。バージョン 0x45 が personal_sign で、人が読めるテキストに使われます。EIP-712(2017年)はバージョン 0x01 で、型付きの構造化データを、アプリケーション、チェーン、検証を行うコントラクトを示すドメインセパレーターとともにハッシュします。つまり EIP-712 は EIP-191 の形式の 1 つであり、規格全体と競合するものではありません。

比較表

EIP-191 personal_sign(バージョン 0x45)EIP-712 型付きデータ(バージョン 0x01)
ステータスFinal(最終版)、2016年1月20日作成Final(最終版)、2017年9月12日作成
ウォレットの RPC メソッドpersonal_signeth_signTypedData_v4
Keccak-256 でハッシュされるバイト列0x19、"Ethereum Signed Message:\n"、長さ、メッセージ0x19、0x01、domainSeparator、hashStruct(message)
ウォレットに表示される内容テキスト。メッセージがバイナリの場合は生の 16 進数フィールド名と値、およびドメイン(名前、チェーン、コントラクト)
リプレイ保護組み込みの仕組みなし。nonce、ドメイン、チェーンをメッセージのテキストに含める必要ありname、version、chainId、verifyingContract、salt を持つドメインセパレーター
コントラクト側での検証プレフィックス付きのメッセージを再構築してから ecrecover型ハッシュと構造体ハッシュを再計算してから ecrecover
主な用途Sign-In with Ethereum(EIP-4361)、アドレス所有の証明permit(EIP-2612)、Permit2、Safe のトランザクション、取引所の注文、ガバナンス投票
署名65 バイトの secp256k1 ECDSA r、s、v同じ
主な落とし穴中身を確認せずに署名される 16 進数のハッシュ、曖昧な自由形式のテキスト無害に見える承認、ハッシュしか表示できないデバイス

それぞれのハッシュ対象

EIP-191 のバージョン 0x45 は、メッセージの前に "\x19Ethereum Signed Message:\n" と 10 進数の ASCII で表したメッセージ長を付け、Keccak-256 でハッシュします。このプレフィックスにより、署名されるバイト列が RLP エンコードされたトランザクションになり得ないことが保証されるため、サイトが送金をメッセージと偽ってウォレットに署名させることはできません。プレフィックスの役割はそれだけです。署名が「example.com にログインする」ことを意味するのか、「無制限の使用を承認する」ことを意味するのかは、受け取る側がテキストをどう解釈するかに完全に依存します。

EIP-712 は、各構造体を、その型文字列のハッシュに続けて各フィールドの 32 バイトのワードを並べたものとしてエンコードします。文字列とバイト列はハッシュされ、アドレスと整数は左側がパディングされ、ネストした構造体はそれ自体のハッシュに置き換えられます。ドメインも同じ方法で EIP712Domain 構造体としてエンコードされます。最終的なダイジェストは keccak256(0x19 0x01 domainSeparator hashStruct(message)) です。

以下のスクリプトは、EIP-712 のリファレンス Example.js にある「Ether Mail」メッセージと署名鍵を使い、Node.js 26 上の @noble/curves 2.3.0 と @noble/hashes 2.3.0 で両方を計算します。この鍵 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 は、EIP-712 のリファレンス Example.js がアサートしている値と完全に一致します。また、署名は決定的(RFC 6979)なので、同じ結果を再現できます。chainId だけを変えるとダイジェストが変わり、これによってチェーン間のリプレイが防がれます。EIP-712 インスペクターでは、任意の型付きデータのペイロードについて同じ中間ハッシュを確認できます。

ユーザーに実際に表示される内容

EIP-712 の主な利点は表示にあります。ウォレットは意味の読み取れない文字列の代わりに「spender」「value」「deadline」を表示できます。ただし、これが役に立つのは、デバイスがフィールドを表示でき、ユーザーがそれを読む場合だけです。資金を抜き取るフィッシングの多くは、現在では完全に有効な EIP-712 署名を求めてきます。たとえば spender に無制限のトークンを与える permit(EIP-2612)や Permit2 の承認で、トランザクションもガスも伴わないため、ユーザーにはオンチェーンの警告が一切表示されません。2025年2月には、Bybit の 3 人の署名者が、ハッシュしか表示できないハードウェアウォレット上で悪意のある Safe トランザクションを承認しました。このトランザクションによってウォレットの実装が置き換えられ、約 15 億ドル(1.5 × 10⁹ ドル)相当の ETH が流出しました。Safe トランザクションハッシュツールのように Safe トランザクションのハッシュを独立に再計算することで、署名者はデバイスに 16 進数で表示される内容を確認できます。

personal_sign には、ちょうど逆の弱点があります。ウォレットはテキストを忠実に表示しますが、サイトは 32 バイトの 16 進文字列への署名をユーザーに求めることができ、その文字列からは何も読み取れません。プレフィックスをまったく付けない生のハッシュへの署名(eth_sign)は、この理由から MetaMask で 2023年にデフォルトで無効化され、2024年に削除されました。

署名の検証

外部所有アカウントでは、どちらの形式も、ダイジェストを再計算して ecrecover(またはライブラリの同等の機能)で署名者を復元し、そのアドレスを比較することで検証します。展性を避けるため、high-s の署名は拒否してください。スマートコントラクトウォレット(Safe、ERC-4337 のアカウント)は自身で ECDSA 署名を生成できないため、検証者は ERC-1271 の isValidSignature(hash, signature) を呼び出し、マジックバリュー 0x1626ba7e が返されたら署名を受け入れる必要があります。ERC-6492 は、これをまだデプロイされていないウォレットにも拡張します。Ethereum 署名検証ツールと SIWE 検証ツールは、どちらのケースにも対応しています。

それぞれの用途

  • ログインとアドレス所有の証明:ドメイン、URI、チェーン ID、nonce、有効期限を含む EIP-4361(SIWE)メッセージを使った EIP-191
  • トランザクションを伴わないトークンの承認:短い期限を設定した EIP-712 の permit または Permit2
  • 注文、投票、メタトランザクション、アテステーション:chainId と verifyingContract を含むドメインを使った EIP-712
  • マルチシグトランザクション:Safe と同じく EIP-712。加えて、別のデバイスでのハッシュの独立した検証
  • 人が読むべき自由形式のテキスト:EIP-191
  • 使ってはいけないもの:ハッシュに対する personal_sign、チェーン ID を含まない EIP-712 ドメイン

移行時の注意点

  1. オフチェーンの認可を personal_sign から EIP-712 に移行する:構造体を定義し、nonce と期限のフィールドを含め、旧形式は発行済みの署名が期限切れになるまでに限って受け付け続けます。
  2. ドメインにバージョンを付ける:フィールドの意味が変わったら version を上げ、古い署名が新しいルールの下で解釈されないようにします。
  3. 署名に使うのと同じライブラリで検証し、上記の EIP-712 のリファレンスのテストベクトルでテストします。
  4. ecrecover を前提とせず、ERC-1271 と ERC-6492 でコントラクトウォレットに対応します。
  5. 取り消しと有効期限:ユーザーが permit を取り消せる手段を用意し、短い期限を使います。有効期限のない署名は無期限に使用可能なままです。

参考資料

すべての比較