【入門編】 EIP-712を用いたオフチェーン署名の安全な実装 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

こんにちは!Web3やスマートコントラクトの開発現場に飛び込んだばかりの皆さん、日々のコーディングお疲れ様です。「ブロックチェーンのセキュリティ」と聞くと、なんだか宇宙飛行士の訓練みたいに難しそうに感じてしまいますよね。

でも、安心してください。セキュリティの本質は、私たちが普段の生活でやっている「戸締まり」や「身元確認」とまったく同じなんです。

今回は、Web3アプリ(dApp)でユーザーに安全にサイン(署名)をもらうための重要技術「EIP-712」について、泥棒に入られないための「家の鍵の仕組み」に例えながら、一歩ずつ優しく紐解いていきましょう!

—

1. なぜ「普通の署名」では危ないの?(家の鍵の例え)

皆さんが新しいお家を建てたと想像してください。玄関の鍵を閉めるのは当たり前ですよね。

従来の暗号資産ウォレット(MetaMaskなど)における「普通のメッセージ署名(eth_signなど)」は、例えるなら「何も書いていない白紙の伝票に、ただ自分のハンコ(秘密鍵)を押すだけ」の状態でした。

もし悪意あるWebサイト(フィッシングサイト)に誘導されて、その白紙の伝票にハンコを押してしまったらどうなるでしょうか?
裏で何が書かれているか分からないまま、攻撃者に「全財産を譲渡します」という契約を勝手に結ばれてしまう危険があります。これが、Web3の世界でよくある「盲目的な署名(Blind Signing)」による資産盗難のメカニズムです。

「自分が今、何に対してハンコを押そうとしているのか」が人間にもプログラムにもハッキリわかるようにする。そのために生まれたのが、型付きデータ署名であるEIP-712なんです!

—

2. EIP-712ってなに?(宅配便の受け取り伝票の仕組み)

EIP-712は、いわば「詳細な宛先や中身が印刷された、偽造できない宅配便の受け取り伝票」です。

この伝票には、次のような大切な情報がカッチリと型(ルール)として決められています。

  • 誰から誰へ?(アドレス)
  • 何を?(金額やアイテム)
  • どこの世界(どのネットワーク、どのスマートコントラクト)の取引?

これによって、ユーザーは「あ、これは〇〇というゲームで、10トークンを引き出すための署名なんだな」と目で見て納得してから安心してハンコを押せるようになります。

—

3. 攻撃者が狙う「リプレイ攻撃」と「ドメインセパレータ」の防衛術

ここで少し意地悪な攻撃を考えてみましょう。
あなたが「テストネット(おもちゃの世界)」で安全な買い物をしたときの伝票のコピーを、悪い奴が盗み見たとします。

もし、その伝票が「本物のメインネット(本番の世界)」でもそのまま使えてしまったら……? テストネットで使ったはずのパスワードや署名で、本番の銀行口座からお金が引き出されてしまいますよね。これを「リプレイ攻撃(使い回し攻撃)」と呼びます。

これを防ぐための防衛ヘッダーのような仕組みが、EIP-712の心臓部である「ドメインセパレータ(Domain Separator)」です。

ドメインセパレータのパラメーター

ドメインセパレータは、いわば「この手紙は、特定の町(チェーン)の、特定のお城(コントラクト)専用だよ」という消印のようなものです。

  • name: アプリケーションの名前(例:「MySuperDApp」)
  • version: アプリのバージョン(例:「1」)
  • chainId: ネットワークのID(例:イーサリアムメインネットなら1、テストネットなら11155111など)
  • verifyingContract: 実際に処理を行うスマートコントラクトのアドレス

これらがガッチリ設定されているおかげで、別のコントラクトや別のネットワークに伝票を持ち込んでも、「あ、これうちの町(チェーン)の伝票じゃないから無効です!」と自動的に弾き返すことができるんです。

—

4. 実装してみよう!EIP-712のコード例

それでは、実際にフロントエンド(JavaScript/TypeScript)とスマートコントラクト(Solidity)で、どのようにEIP-712を実装するのか見ていきましょう。

① フロントエンド側(JavaScript / ethers.js)の記述

ユーザーのウォレット(MetaMaskなど)に「安全な伝票」を表示させるコードです。

// EIP-712で使うドメイン(お城の消印情報)を定義します
const domain = {
  name: "MySecureDApp",
  version: "1",
  chainId: 1, // イーサリアムメインネットのID
  verifyingContract: "0x1234567890abcdef1234567890abcdef12345678", // 処理を行うコントラクトの住所
};

// データの「型(構造)」を定義します(宅配伝票のフォーマット)
const types = {
  Permit: [
    { name: "owner", type: "address" },
    { name: "spender", type: "address" },
    { name: "value", type: "uint256" },
    { name: "nonce", type: "uint256" },
  ],
};

// 実際にユーザーがサインする中身のデータです
const value = {
  owner: "0xYourWalletAddressHere...",
  spender: "0xReceiverAddressHere...",
  value: 1000000000000000000n, // 1トークン (Wei単位)
  nonce: 0, // 使い回しを防ぐためのカウンター
};

async function signEIP712Data(signer) {
  try {
    // ユーザーのウォレットに対して、安全な型付きデータの署名を依頼します
    // これにより、MetaMaskの画面に詳細な内容が分かりやすく表示されます!
    const signature = await signer.signTypedData(domain, types, value);
    
    console.log("署名成功!シグネチャ:", signature);
    return signature;
  } catch (error) {
    console.error("ユーザーによって署名がキャンセルされました", error);
  }
}

② スマートコントラクト側(Solidity)の検証

フロントエンドから送られてきた署名が本当に正しいものか、スマートコントラクト側でしっかりと検問(バリデーション)します。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

// OpenZeppelinのEIP712ヘルパーを継承することで、安全な実装が簡単に作れます!
contract SecureVault is EIP712 {
    
    // コンストラクタでアプリ名とバージョンを指定し、自動的にドメインセパレータを構築します
    constructor() EIP712("MySecureDApp", "1") {}

    // 署名の型ハッシュを定義(フロントエンドの types と完全に一致させる必要があります)
    bytes32 private constant PERMIT_TYPEHASH = keccak256(
        "Permit(address owner,address spender,uint256 value,uint256 nonce)"
    );

    // 署名を検証して中身を取り出す関数
    function verifySignature(
        address owner,
        address spender,
        uint256 value,
        uint256 nonce,
        bytes memory signature
    ) public view returns (bool) {
        
        // 1. EIP-712のルールに従って、送られてきたデータをハッシュ化します
        bytes32 structHash = keccak256(
            abi.encode(
                PERMIT_TYPEHASH,
                owner,
                spender,
                value,
                nonce
            )
        );

        // 2. ドメインセパレータと組み合わせた最終的なハッシュを生成します
        bytes32 hash = _hashTypedDataV4(structHash);

        // 3. 誰がこのハッシュに署名したのかを復元し、ownerと一致するか確認します
        address signer = ECDSA.recover(hash, signature);
        
        require(signer == owner, "Invalid signature: 署名が一致しません!");
        
        return true;
    }
}

—

5. まとめ:安全なWeb3開発への第一歩

いかがでしたでしょうか? EIP-712という難しい名前も、仕組みを分解してしまえば「ユーザーが内容をしっかり確認できて、別の場所での使い回しもできない、安全なデジタル伝票の仕組み」だと分かりますよね。

実務でdAppを開発・運用する際は、次のポイントを必ずチェックするようにしてください。

1. 白紙の署名(eth_signなど)は絶対に使わない・使わせない!
2. フロントエンドとスマートコントラクトの間で、データの型定義(typesとPERMIT_TYPEHASH)にズレがないか必ずテストする!
3. ドメインセパレータの chainId や verifyingContract が本番環境とテスト環境で正しく切り替わっているか確認する!

セキュリティの基本は「疑うこと」と「分かりやすくすること」の両立です。一歩ずつ、堅牢で安全なコードを書けるエンジニアを目指して一緒に頑張っていきましょう!

コメント

タイトルとURLをコピーしました