Skip to content
On this page

L2-9: Sign + ecrecover(署名検証)

1. 課題

イーサリアムにおいて、オフチェーン署名 + オンチェーン検証は「ガスレス取引」と「メタ取引」を実現する中核的なパラダイムです。ユーザーは自分の秘密鍵でオフチェーンでメッセージに署名し(ガスコストゼロ)、署名をオンチェーンに提出します。誰でも代わりに取引を実行してガスを支払うことができます。しかし、ここにはいくつかの重要な問題が潜んでいます。署名形式の異なるプレフィックス標準(eth_sign の "\x19Ethereum Signed Message:\n32" vs EIP-712 の "\x19\x01")が署名のセキュリティドメインを決定します。ecrecover プリコンパイルが不正な形式の署名に対して address(0) を返す処理方法、v 値の異なるエンコード方式(27/28 vs 0/1)がライブラリ間で互換性の問題を引き起こします。

このチャレンジ(src/level2/SignatureVerifier.sol を参照)では、2 つの署名検証方法の実装が求められます。標準の eth_sign 検証と EIP-712 Permit 検証です。これらは EIP-2612 Permit 承認、ガスレストークン転送、およびすべてのメタ取引パターンの暗号学的基盤です。

2. 理由

署名検証はイーサリアム暗号学の 3 段階の飛躍を表します。「ethers.signMessage() を呼び出せる」から「eth_sign のプレフィックスの魔法を理解する」へ、そして最後に「EIP-712 型付き署名を実装できる」へ。各段階は異なるセキュリティレベルに対応します。

eth_sign のプレフィックス "\x19Ethereum Signed Message:\n32" はランダムな文字列ではありません。署名のクロスプロトコルリプレイ攻撃を防ぐために特別に設計されています。ビットコインの初期には、ある取引の署名がまったく異なる別の取引にコピーされても有効であることが発見されました。イーサリアムは、すべての署名データの前にプレフィックスを強制的に追加することでこの問題を解決します。このプレフィックスがなければ、ある DApp のために署名したメッセージが別の DApp の署名検証ロジックにリプレイされる可能性があります。

EIP-712 はさらに進んでいます。プレフィックスを追加するだけでなく、「どのコントラクト(verifyingContract)のどのチェーン(chainId)で有効か」という完全なドメイン情報も付加します。つまり、2 つの DApp が完全に同じ Permit データ構造を使用していても、署名は互いにリプレイされません。"\x19\x01"0x01 は EIP-712 のバージョン識別子です。将来 EIP-712 がアップグレードされた場合、このバイトを変更して後方互換性を確保できます。

ecrecover の動作を理解することは、基盤となる暗号学を把握する鍵です。ecrecover(messageHash, v, r, s) は ECDSA 署名から署名者の公開鍵(ひいてはアドレス)を復元します。「署名を検証する」のではなく、「署名を復元する」のです。有効な (v, r, s) のトリプレットはすべてアドレスを復元できますが、そのアドレスが期待される署名者であることを確認するには、復元後に比較する必要があります。

3. 解決策

SignatureVerifier コントラクト(src/level2/SignatureVerifier.sol を参照)は 2 つの検証方法と完全な EIP-712 インフラストラクチャを提供します:

方法 1:eth_sign 標準署名検証

solidity
function verifyEthSign(
    bytes32 message,
    uint8 v,
    bytes32 r,
    bytes32 s
) public pure returns (address signer) {
    // イーサリアム署名メッセージプレフィックスを構築
    bytes32 ethSignedMessageHash = keccak256(
        abi.encodePacked("\x19Ethereum Signed Message:\n32", message)
    );
    // 署名からアドレスを復元
    signer = ecrecover(ethSignedMessageHash, v, r, s);
    if (signer == address(0)) revert InvalidSignature();
    return signer;
}

キー詳細:

  • プレフィックス "\x19Ethereum Signed Message:\n32"personal_sign および eth_sign 標準と互換性があります
  • message パラメータは 32 バイトのハッシュ値であることが期待されます
  • ecrecover は address(0) を返して署名が無効であることを示します(例:v 値が [27, 28] の範囲外、または s 値が高すぎる場合)

方法 2:EIP-712 Permit 型付き署名検証

solidity
bytes32 public immutable DOMAIN_SEPARATOR;
bytes32 public immutable PERMIT_TYPEHASH;

bytes32 private constant _PERMIT_TYPEHASH =
    keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

constructor() {
    DOMAIN_SEPARATOR = keccak256(abi.encode(
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
        keccak256(bytes("SignatureVerifier")),
        keccak256(bytes("1")),
        block.chainid,
        address(this)
    ));
    PERMIT_TYPEHASH = _PERMIT_TYPEHASH;
}

function verifyEIP712Permit(
    address owner, address spender, uint256 value,
    uint256 deadline, uint8 v, bytes32 r, bytes32 s
) public returns (bool) {
    if (block.timestamp > deadline) revert ExpiredPermit(deadline, block.timestamp);

    // 構造化データハッシュを構築
    bytes32 structHash = keccak256(abi.encode(
        PERMIT_TYPEHASH, owner, spender, value,
        nonces[owner]++,   // nonce をインクリメントしてリプレイを防止
        deadline
    ));

    // EIP-712 ドメイン分離された最終ダイジェストを構築
    bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));

    address signer = ecrecover(digest, v, r, s);
    if (signer == address(0) || signer != owner) {
        revert WrongSigner(owner, signer);
    }

    emit PermitVerified(owner, spender, value);
    return true;
}

EIP-712 署名構築の階層構造

1. TYPEHASH  = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
2. structHash = keccak256(TYPEHASH || owner || spender || value || nonce || deadline)
3. DOMAIN_SEPARATOR = keccak256(EIP712Domain || name || version || chainId || verifyingContract)
4. digest = keccak256("\x19\x01" || DOMAIN_SEPARATOR || structHash)
5. ecrecover(digest, v, r, s) → signer

オフチェーン署名構築(JavaScript / ethers.js)

javascript
const domain = {
    name: 'SignatureVerifier',
    version: '1',
    chainId: (await provider.getNetwork()).chainId,
    verifyingContract: contractAddress,
};
const types = {
    Permit: [
        { name: 'owner', type: 'address' },
        { name: 'spender', type: 'address' },
        { name: 'value', type: 'uint256' },
        { name: 'nonce', type: 'uint256' },
        { name: 'deadline', type: 'uint256' },
    ],
};
const nonce = await contract.nonces(wallet.address);
const message = { owner: wallet.address, spender, value, nonce, deadline };
const signature = await wallet.signTypedData(domain, types, message);
const { v, r, s } = ethers.Signature.from(signature);

4. 遭遇した落とし穴

  • ecrecover が address(0) を返す:v 値が 27 または 28(または対応する 0、1)でない場合、または s 値が secp256k1n / 2 を超える場合、ecrecover は revert せずに address(0) を返します。明示的にチェックしないと署名検証を通過してしまいます
  • プレフィックスの混同"\x19Ethereum Signed Message:\n32"(eth_sign)と "\x19\x01"(EIP-712)のプレフィックスを混同すると、署名が永遠に検証を通過しません
  • メッセージハッシュ長が 32 バイトでない:eth_sign のプレフィックス \n32 はメッセージ長が正確に 32 バイトであることを明示的に期待しています。任意の長さの生メッセージを渡すと署名が一致しません
  • 不適切な nonce 管理:EIP-712 検証で nonce のインクリメントを忘れたり、誤った nonce 順序を使用したりすると、署名リプレイ攻撃につながります
  • v 値エンコード方式の違い:ethers.js の Signature.from() が返す v は 27/28 ですが、一部のライブラリは 0/1 を返します。オンチェーンで 27/28 のチェックをハードコードすると、有効な署名を拒否する可能性があります
  • 不十分な deadline チェック>= の代わりに > を使用すると、ちょうど deadline の瞬間に提出された正当な署名が拒否されます

5. 落とし穴の原因

ecrecover のサイレント失敗は、そのプリコンパイル実装の設計上の選択です。EVM プリコンパイルコントラクト 0x01(ecrecover)は内部的に secp256k1 楕円曲線演算を呼び出します。入力パラメータが曲線群の有効範囲外の場合(例:v が 27/28 でない、s が n/2 を超える)、空の結果(64 ゼロバイト)を返し、Solidity はそれを address(0) と解釈します。この動作は非常に危険です。signer == expectedOwner のみをチェックする場合、address(0) != expectedOwner は正しく失敗します。しかし、誤って signer != address(0) のみをチェックする(これで十分だと考えて)と、巧妙に作成された無効な署名がこれを迂回する可能性があります。より一般的な問題は、signer != address(0) のみをチェックして signer と expectedOwner を比較しないことです。

プレフィックスシステムは署名ドメイン分離のために存在します。"\x19" プレフィックスと特定のメッセージ形式がなければ、コントラクト A のために署名された Permit をコントラクト B に直接コピーしてリプレイできます。EIP-712 の "\x19\x01" は DOMAIN_SEPARATOR(chainId + verifyingContract を含む)を付加することで、2 つのコントラクトが完全に同じ Permit 構造を持っていても、署名が 1 つのコントラクトの 1 つのチェーンでのみ有効であることを保証します。

nonce は署名リプレイを防ぐための重要なメカニズムです。nonce がない(または nonce がインクリメントされない)場合、ユーザーが一度 Permit に署名すると、誰でも同じ (v, r, s) トリプレットをオンチェーンで無限に提出できます。インクリメントする nonce により、各署名は 1 回のみ使用可能になります。

6. 落とし穴の解決方法

常に ecrecover の戻り値の両方の条件をチェックします。address(0) でないこと、かつ期待される署名者アドレスと等しいこと:

solidity
address signer = ecrecover(digest, v, r, s);
if (signer == address(0) || signer != owner) {
    revert WrongSigner(owner, signer);
}

コンストラクタで DOMAIN_SEPARATOR を正しく構築します。文字列の名前とバージョンを直接 bytes として渡すのではなく、keccak256 を使用してハッシュします。これにより EIP-712 標準との互換性が確保されます(標準ではハッシュされた値の使用が要求されます):

solidity
DOMAIN_SEPARATOR = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256(bytes("SignatureVerifier")),  // ハッシュされた名前
    keccak256(bytes("1")),                  // ハッシュされたバージョン
    block.chainid,
    address(this)
));

nonce 管理については、検証時に nonces[owner]++ を使用してその場でインクリメントします。これにより各検証が異なる nonce を使用し、リプレイの試みはすべて nonce の不一致により失敗します。フロントエンドが現在の nonce 値を取得できるように getNonce() クエリ関数を提供します。

deadline チェックについては、>= ではなく厳密な大なり > を使用し、ちょうど deadline ブロックでの署名を許可します。また、現在時刻と deadline の差をユーザーに知らせる意味のあるエラーメッセージを提供します。

solidity
if (block.timestamp > deadline) revert ExpiredPermit(deadline, block.timestamp);

7. 技術的要点

要点説明
eth_sign プレフィックス"\x19Ethereum Signed Message:\n32" + keccak256(message)
EIP-712 プレフィックス"\x19\x01" + DOMAIN_SEPARATOR + structHash
DOMAIN_SEPARATORkeccak256(name, version, chainId, verifyingContract)
ecrecover 失敗時の戻り値address(0)(revert しない、手動チェック必須)
v 値の範囲27/28(EIP-155 スタイル)または 0/1、ecrecover は両方に対応
nonce によるリプレイ防止各検証で nonces[owner]++ をインクリメント、署名の再利用を防止
型ハッシュkeccak256("Permit(address owner,...)") — 構造定義の標準化された表現
structHash`keccak256(TYPEHASH

Built with AiAda