はじめに
『DApps開発入門』という本や色々記事を書いているかるでねです。
今回は、複数のスマートアカウントを1つのEOAで管理する場合に発生する署名の不正再利用(リプレイ攻撃)を、防御的リハッシュとEIP-712型構造を組み合わせて安全に防ぐ方法を標準化しているERC7739についてまとめていきます!
以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。
他にも様々なEIPについてまとめています。
概要
ERC7739は、1つの外部所有アカウント(EOA)が複数のスマートアカウントを所有している場合に発生する、署名のリプレイ攻撃(過去に使用した署名を再利用して不正に取引を成立させる攻撃)を防ぐための標準仕様を定義しています。
防御策として、ERC1271の署名検証においてネストされたEIP712型構造体を用いた「防御的リハッシュ(defensive rehashing)」を行います。
この方法により、ウォレットの署名リクエスト時に署名対象の内容を人間が読める形で保持できます。
動機
スマートアカウントは、ERC1271に準拠した isValidSignature
関数で署名の正当性を検証します。
しかし、単純な実装では脆弱性があり、複数のスマートアカウントを1つのEOAが管理している場合、署名にスマートアカウントのアドレスが含まれなければ同じ署名が別のアカウントでも使えてしまう(リプレイ可能)という問題があります。
ERC1271については以下の記事を参考にしてください。
特に、Permit2など多くの人気アプリケーションではこの問題が発生する可能性があります。
そのため、多くのスマートアカウント実装では、防御的リハッシュとして以下のような追加情報をハッシュに含める手法を採用しています。
- 元のハッシュ値
- スマートアカウント自身のアドレス
- チェーンID
これにより、異なるアカウントや異なるチェーンでは同じ署名が通用しなくなります。
ただし、EIP712を用いて単純に実装すると、署名対象のデータがウォレットクライアント上で人間から見えない不透明な形式になってしまいます。
EIP712については以下の記事を参考にしてください。
ERC7739の方式では、EIP712に対応したウォレットであれば署名内容を可視化でき、既存のウォレットクライアントやアプリケーションフロントエンドの改修が不要です。
さらに、必要であればユーザーがクライアント側のJavaScriptを追加することで、防御的リハッシュを有効化することもできます。
仕様
ERC7739は、ERC1271の isValidSignature
関数における署名検証に、防御的リハッシュ(Defensive Rehashing)を導入する方法を定義しています。
目的は、1つの外部所有アカウント(EOA)が複数のスマートアカウントを所有している場合に発生する署名リプレイ攻撃を防ぎつつ、署名内容をウォレットで可視化できるようにすることです。
この仕組みでは、2種類のワークフローを想定しています。
- TypedDataSign ワークフロー(EIP712形式の署名)
- PersonalSign ワークフロー(EIP191形式の署名)
それぞれに応じて最終ハッシュの計算方法や署名フォーマットが異なります。
EIP191については以下の記事を参考にしてください。
必要な依存仕様
この標準を実装するには以下が必要です。
-
EIP712
- 型付き構造データのハッシュ化と署名方式。
- 最終ハッシュを構築するためのロジックを提供します。
-
ERC1271
- コントラクト署名の標準検証メソッド。
isValidSignature(bytes32 hash, bytes calldata signature)
関数を提供します。
- コントラクト署名の標準検証メソッド。
-
ERC5267
- EIP712ドメイン情報取得仕様。
-
eip712Domain()
関数でドメイン情報を取得し、最終ハッシュの計算に利用します。
ERC5267については以下の記事を参考にしてください。
TypedDataSign
ワークフロー
EIP712形式で計算された元ハッシュを、さらにスマートアカウント固有の情報を加えてリハッシュする方式です。
これにより、アカウントやチェーンごとに異なる署名となり、リプレイ攻撃を防ぎます。
最終ハッシュの計算式
keccak256(
\x19\x01 ‖ APP_DOMAIN_SEPARATOR ‖
hashStruct(TypedDataSign({
contents: hashStruct(originalStruct),
name: eip712Domain().name,
version: eip712Domain().version,
chainId: eip712Domain().chainId,
verifyingContract: eip712Domain().verifyingContract,
salt: eip712Domain().salt
}))
)
この処理は最終的な署名対象ハッシュの作り方を示しています。
目的は、元の署名対象データ(originalStruct
)に加え、スマートアカウント固有の情報(ドメイン情報やアカウント固有の定数)を含めた新しいハッシュを作ることで、署名の再利用(リプレイ)を防ぎつつ、EIP712形式の署名可視化を維持することです。
式の構造は、EIP712の \x19\x01
プレフィックスに、アプリケーション固有のドメインセパレーター(APP_DOMAIN_SEPARATOR
)と、TypedDataSign構造体のハッシュを結合し、その結果をkeccak256でハッシュ化します。
計算の流れ
- 固定プレフィックス
\x19\x01
これはEIP712で定義されている識別子で、「このデータは型付き構造体の署名用である」ということを示します。
APP_DOMAIN_SEPARATOR
アプリケーション固有のドメイン識別子です。
これを追加することで、同じ構造のデータでも別アプリケーションや別用途で使い回せないようにします。
hashStruct(TypedDataSign({...}))
新たに定義した TypedDataSign
構造体をハッシュ化します。
この構造体は以下のフィールドを持ちます。
-
contents
- 元データ構造(
originalStruct
)のハッシュ。
- 元データ構造(
-
name
-
eip712Domain().name
の文字列ハッシュ。
-
-
version
- ドメインバージョンの文字列ハッシュ。
-
chainId
- チェーンID。
-
verifyingContract
- 検証を行うコントラクトのアドレス。
-
salt
- ドメイン固有の32バイトの識別子。
このように、元データの内容+アカウント固有情報+チェーン固有情報を全て含めたハッシュが作られます。
Solidity実装例
finalTypedDataSignHash =
keccak256(
abi.encodePacked(
hex"1901", // (1) 固定プレフィックス
bytes32(APP_DOMAIN_SEPARATOR), // (2) アプリ固有ドメイン
keccak256( // (3) TypedDataSign構造体のハッシュ
abi.encode(
typedDataSignTypehash, // 型情報のハッシュ
bytes32(hashStruct(originalStruct)), // 元構造体のハッシュ
keccak256(bytes(eip712Domain().name)),
keccak256(bytes(eip712Domain().version)),
uint256(eip712Domain().chainId),
uint256(uint160(eip712Domain().verifyingContract)),
bytes32(eip712Domain().salt)
)
)
)
);
typedDataSignTypehash
の計算方法
typedDataSignTypehash
は「TypedDataSignという構造体の型定義」をハッシュ化したものです。
これを入れることで、「どういう型構造のデータが署名されているのか」をウォレットクライアントが理解できます。
計算式は以下です。
typedDataSignTypehash =
keccak256(
abi.encodePacked(
"TypedDataSign(",
contentsName, " contents,", // ユーザー定義型のフィールド
"string name,",
"string version,",
"uint256 chainId,",
"address verifyingContract,",
"bytes32 salt"
")",
contentsType // contentsNameの完全な型定義
)
);
contentsName
は contentsType
(例: "Mail(address from,address to,string message)"
)から、最初の "("
の手前までを切り出した部分文字列です。
例)
-
contentsType
→"Mail(address from,address to,string message)"
-
contentsName
→"Mail"
コードでは、ライブラリ LibString
の slice
と indexOf
関数で抽出します。
contentsName =
LibString.slice(
contentsType, // 対象文字列
0, // 開始位置(先頭)
LibString.indexOf(contentsType, "(") // "(" の位置まで
);
セキュリティ上、contentsName
が空である場合や特定の不正文字を含む場合は署名を無効とすることが推奨されています。
署名フォーマット
isValidSignature
に渡す署名は以下のように拡張されます。
originalSignature ‖ APP_DOMAIN_SEPARATOR ‖ contents ‖ contentsDescription ‖ uint16(contentsDescription.length)
Solidity実装例。
signature =
abi.encodePacked(
bytes(originalSignature),
bytes32(APP_DOMAIN_SEPARATOR),
bytes32(contents),
bytes(contentsDescription),
uint16(contentsDescription.length)
);
この APP_DOMAIN_SEPARATOR
と contents
を使い、次の式でハッシュが正しいかを検証します。
hash == keccak256(
abi.encodePacked(
hex"1901",
bytes32(APP_DOMAIN_SEPARATOR),
bytes32(contents)
)
)
一致しない場合、その署名とハッシュは無効となります。
PersonalSign ワークフロー
EIP191形式の署名を扱う方式で、Ethereum メッセージ署名のプレフィックスを含めてハッシュを生成します。
PersonalSignワークフローは、プレーン文字列の署名(personal_sign
/eth_sign
系)でよく使われる EIP191形式を、EIP712のように変換することで、スマートアカウント固有の文脈(ドメイン)にひも付けます。
これにより、同じメッセージでもスマートアカウントやチェーンが異なる環境では再利用(リプレイ)できないようにしつつ、アプリ側は従来どおりEIP191のハッシュ(hash
)だけ渡せばよい、という互換性を保ちます。
最終ハッシュの計算式
keccak256(
\x19\x01 ‖ ACCOUNT_DOMAIN_SEPARATOR ‖
hashStruct(PersonalSign({
prefixed: keccak256(bytes(
\x19Ethereum Signed Message:\n ‖
base10(len) ‖
someString
))
}))
)
-
\x19\x01
はEIP712の識別プレフィックスです。 -
ACCOUNT_DOMAIN_SEPARATOR
はスマートアカウント自身のEIP712ドメイン識別子です(ERC5267のeip712Domain()
から求められます)。- アカウントとチェーンに結び付けるために使用されます。
-
PersonalSign(bytes prefixed)
という単純な1フィールドの型でEIP191の「プレフィックス付きメッセージハッシュ」を内包します。- これが
hashStruct(PersonalSign({...}))
の部分です。
- これが
Solidity実装例
finalPersonalSignHash =
keccak256(
abi.encodePacked(
hex"1901", // 1) EIP-712識別プレフィックス
bytes32(ACCOUNT_DOMAIN_SEPARATOR), // 2) スマートアカウント固有のドメイン
keccak256(
abi.encode(
keccak256("PersonalSign(bytes prefixed)"), // 3) 型定義のtypehash
hash // 4) EIP-191のメッセージハッシュ
)
)
)
);
ここで hash
はアプリケーションコントラクト側で計算されます。
例:
hash = keccak256(
abi.encodePacked(
"\x19Ethereum Signed Message:\n",
LibString.toString(someString.length), // 10進ASCIIで長さ
someString // 元メッセージの生バイト列
)
);
-
EIP191の前置き(
"\x19Ethereum Signed Message:\n" + 10進の長さ + 本文
)を自前で連結してkeccak256でハッシュ化した値がhash
です。
長さは16進ではなく10進ASCIIで表記します(例:"5"
、"32"
)。 -
someString
はそのままのバイト列で扱います。- 文字列ならUTF-8の生バイト、任意バイナリでもOKです。
- スマートアカウント側は
hash
の生成手順を知る必要はありません。- アプリが計算した
hash
を受け取って二重にラップするだけです。
- アプリが計算した
PersonalSignではTypedDataSignのような追加データは不要です。
なぜEIP712でラップするのか
PersonalSignは「誰が・どの文脈で」署名したかのメタ情報を持ちません。
そこでEIP712に準拠することで、ACCOUNT_DOMAIN_SEPARATOR
を通じてアカウントのアドレスやチェーンID、バージョン等のドメイン情報を最終ハッシュへ反映します。
結果として、同じ hash
でも別アカウント/別チェーンでは検証が通らないため、リプレイ攻撃が防げます。
手順(実装の流れ)
- アプリ側でEIP191形式に従い
hash
を計算します(上記例)。 - スマートアカウント側で
ACCOUNT_DOMAIN_SEPARATOR
を用意します(eip712Domain()
情報から計算可能)。 -
keccak256("PersonalSign(bytes prefixed)")
を typehash として用い、abi.encode(typehash, hash)
を keccak256 します。 -
hex"1901" || ACCOUNT_DOMAIN_SEPARATOR || (手順3の結果)
をabi.encodePacked
で連結して keccak256。- これが
finalPersonalSignHash
です。
- これが
- 署名検証は、この
finalPersonalSignHash
と署名をecrecover
等(ERC-1271の内部方針)で照合します。
サポート検出
この標準を実装したスマートアカウントは、以下の呼び出しに対して特定の値を返すことで対応を示します。
isValidSignature(
0x7739773977397739773977397739773977397739773977397739773977397739,
""
)
// => bytes4(0x77390001)
将来アップデートされる場合は、この値をインクリメントします。
ワークフロー判定ロジック
isValidSignature
の関数シグネチャは変わらないため、署名データからどちらのワークフローを使うかを判別します。
if (
hash == keccak256(
abi.encodePacked(
hex"1901",
bytes32(APP_DOMAIN_SEPARATOR),
bytes32(contents)
)
)
) {
// TypedDataSign ワークフロー
} else {
// PersonalSign ワークフロー
}
防御的リハッシュの省略条件
以下の場合、防御的リハッシュをスキップできます。
-
isValidSignature
がオフチェーンで呼び出される場合 - 渡された
hash
にスマートアカウントのアドレスが既に含まれている場合
これは、既存のアプリが新しい仕様に対応していなくても、安全な場合は旧来の検証を行えるようにするためです。
補足
TypedDataSign 構造の設計意図
typedDataSignTypehash
は、オンチェーン上で動的に生成する必要があります。
これは、署名リクエスト時にウォレットでユーザーが署名内容を確認できるようにするためであり、contents
がユーザー定義型であることを保証します。
また、eip712Domain
の各フィールドは TypedDataSign
構造体内に直接展開されます。
これは、verifyingContract
側のドメイン型と異なる場合に型の衝突が発生するのを避けるためです。
さらに、ERC5267に存在する bytes1 bitmap
フィールドや uint256[] extensions
配列は省略されています。
これらはフィールドが存在しない場合とゼロ値の場合を区別できるものの、防御的リハッシュのセキュリティ向上には寄与しないためです。
extensions
はオフチェーンでEIP番号を通知する目的で使われる項目です。
contentsDescription の暗黙モードと明示モード
contents
構造がネストされた型を含む場合、EIP712の辞書順ソートによって contentsName
が contentsType
の先頭に来ないことがあります。
このようなケースに対応するため、明示モード(explicit mode)が必要になります。
暗黙モード(implicit mode)は、contentsType
の先頭が contentsName
である場合に使用されます。
isValidSignature によるサポート検出
モジュール型スマートアカウントでの実装を簡単にするため、新たな関数を定義せずにisValidSignature
の戻り値として特定のマジックナンバーを返す方式を採用しています。
これにより、追加のインターフェース定義を不要にしつつ、標準対応を判別できます。
contentsName の先頭文字制限
contentsName
が7ビットASCIIの小文字で始まる場合は署名を拒否することが推奨されています。
これは、言語間の互換性と将来の拡張性を確保するためです。
例えば、Solidityでは uint256
と表記する型が、他言語では u256
のように異なる名称になる可能性があります。
互換性について
過去のドラフトとの違い
以前のドラフト版では、標準対応を検出するために supportsNestedTypedDataSign()
という関数が定義されており、この関数は bytes4(0xd620c85a)
を返していました。
現在の仕様ではこの方式を廃止し、isValidSignature
でのマジックナンバー返却に統一されています。
これにより、既存の**ERC1271&&互換コードへの影響を最小限に抑えつつ、新仕様への移行が容易になります。
セキュリティ
不正な contentsName
の拒否
eth_signTypedData
を利用する主要な実装では、カスタム型名(contentsName
)の内容を厳密に検証していない場合があります。
このため、悪意のあるWebサイトが制御文字や特殊記号を含んだ contentsName
を細工すると、EIP712署名データの型定義部分を意図的に壊すことが可能です。
これにより、ウォレットクライアントが正しい型構造を表示できず、ユーザーには単なる不透明なハッシュが署名対象として提示される危険性があります。
この攻撃は、ユーザーに署名内容を理解させずに署名させるフィッシング攻撃の一種です。
そのため、スマートコントラクト側で contentsName
を検証し、無効な文字列を拒否することが推奨されます。
これにより、この攻撃経路を遮断できます。
複数署名者による連鎖利用の不可能性
この方式をERC1271署名のリプレイ防止策として採用したアカウントは、同じ方式を使う別の署名者を組み合わせることができません。
理由は、TypedDataSign
構造体の中に「署名対象のメッセージ型」が含まれているためです。
もし署名対象のメッセージ自体が別の TypedDataSign
構造体だった場合、EIP712メッセージ本体には2つの異なる TypedDataSign
型が混在することになります。
しかし、EIP712の仕様上、これらの型定義を一つの署名リクエストとして表現することはできません。
その結果、同じ防御的リハッシュ方式を複数の署名者で階層的に組み合わせることは技術的に不可能となります。
引用
vectorized (@vectorized), Sihoon Lee (@push0ebp), Francisco Giordano (@frangio), Hadrien Croubois (@Amxx), Ernesto García (@ernestognw), Im Juno (@junomonster), howydev (@howydev), Atarpara (@Atarpara), 0xcuriousapple (@0xcuriousapple), "ERC-7739: Readable Typed Signatures for Smart Accounts [DRAFT]," Ethereum Improvement Proposals, no. 7739, May 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7739.
最後に
今回は「複数のスマートアカウントを1つのEOAで管理する場合に発生する署名の不正再利用(リプレイ攻撃)を、防御的リハッシュとEIP-712型構造を組み合わせて安全に防ぐ方法を標準化しているERC7739」についてまとめてきました!
いかがだったでしょうか?
質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!
他の媒体でも情報発信しているのでぜひ他も見ていってください!