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

EIP-712の落とし穴:なぜ「なんとなく署名」が資産を溶かすのか

現場で数多のスマートコントラクト監査を行っていると、必ずと言っていいほど遭遇するのが「オフチェーン署名の設計ミス」だ。

「メタトランザクション(ガス代を肩代わりする仕組み)」や「オフチェーンでの承認プロセス」を実装する際、開発者はよく eth_sign を使いがちだ。だが、あれは悪夢の入り口だ。署名データが生の文字列(Hex)として投げられると、ユーザーは自分が「何に」署名しているのかすら理解できない。これを突くのがフィッシング攻撃の常套手段だ。

そこで登場するのが EIP-712 である。これはデータを型定義し、人間に読める形(Domain Separator)で提示するための標準仕様だ。今回は、この実装でエンジニアが陥りがちな罠と、現場で通用する「コピペ可能なセキュア実装」を伝授する。

—

1. 攻撃者が狙う盲点:「ドメインセパレータの使い回し」

EIP-712における最大の脆弱性は、DOMAIN_SEPARATOR の不適切な管理にある。多くのプロジェクトが、本番環境とテスト環境、あるいは複数のコントラクト間で同じドメインセパレータを使い回している。

攻撃シナリオ:
攻撃者は、あなたが別のプロジェクトで使っている署名要求をキャプチャし、それを全く別のコントラクトの permit 関数や execute 関数に流し込む(リプレイ攻撃)。もし chainId や verifyingContract が検証ロジックに含まれていない、あるいは固定値であれば、その署名はどこでも通用する「万能鍵」と化す。

—

2. セキュアな実装:JavaScript (Frontend) 側

まずはフロントエンドでの署名生成コードだ。ethers.js v6を使用し、型定義を厳格に行う。

// EIP-712の型定義と署名処理
const domain = {
  name: 'MySecureProtocol',
  version: '1',
  chainId: 1, // メインネットのみに限定
  verifyingContract: '0xYourContractAddressHere', // コントラクトアドレスをハードコード
};

const types = {
  Permit: [
    { name: 'owner', type: 'address' },
    { name: 'spender', type: 'address' },
    { name: 'value', type: 'uint256' },
    { name: 'nonce', type: 'uint256' },
    { name: 'deadline', type: 'uint256' },
  ],
};

const message = {
  owner: userAddress,
  spender: spenderAddress,
  value: 1000,
  nonce: currentNonce, // 必ずコントラクトから取得した最新のnonceを使うこと
  deadline: Math.floor(Date.now() / 1000) + 3600, // 有効期限は必須
};

// 署名実行(MetaMask等のウォレットに型付きデータを表示させる)
const signature = await signer.signTypedData(domain, types, message);

ポイント: nonce と deadline を忘れるな。これが欠けていると、署名データが流出した瞬間に資産が無限に引き抜かれる。

—

3. セキュアな実装:Solidity (Contract) 側

コントラクト側で検証を怠れば、全ては水の泡だ。OpenZeppelinの EIP712 ライブラリを使うのが最も堅牢だ。

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

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

contract SecureVault is EIP712 {
    // コンストラクタでドメイン名を定義
    constructor() EIP712("MySecureProtocol", "1") {}

    function execute(address owner, bytes32 structHash, bytes memory signature) public view {
        // _hashTypedDataV4 はEIP-712の仕様に基づき、Domain Separatorを自動で付与する
        bytes32 digest = _hashTypedDataV4(structHash);
        address signer = ECDSA.recover(digest, signature);
        
        require(signer == owner, "Invalid signature");
        // ここで処理を実行
    }
}

—

4. 現場の教訓:インフラ側の防衛線

コードがどれほど完璧でも、バックエンドのAPIが脆弱であれば意味がない。オフチェーン署名を扱うAPIサーバーは、以下の設定を徹底せよ。

Nginxの設定(レートリミットによるDoS対策):

# 署名検証エンドポイントへの過剰なリクエストを遮断
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=5r/s;

location /api/v1/verify-signature {
    limit_req zone=api_limit burst=10 nodelay;
    proxy_pass http://backend_app;
}

IAM/クラウド環境のTips:

  • 署名検証キーの管理: 署名検証を行うバックエンドサーバーには、Secrets Manager や KMS を活用せよ。環境変数に秘密鍵をベタ書きするのは、玄関に鍵を置いて出かけるのと同じだ。
  • 監査ログ: 署名の nonce は必ずDBに記録し、一度使われた nonce が再利用(リプレイ)されないよう、DB側でユニーク制約をかけておくこと。

—

最後に:セキュリティは「疑うこと」から始まる

EIP-712は非常に強力だが、開発者が「仕様の意図」を理解していないと、ただの複雑な手続きになってしまう。
「なぜこの chainId を入れているのか?」「なぜ deadline が必要なのか?」――コードを書くたびに自分に問いかけてほしい。

セキュリティリサーチャーとしての経験則だが、最も危険なのは「ライブラリがやってくれるから大丈夫」という慢心だ。常に Domain Separator の中身を検証し、リプレイ攻撃の余地を徹底的に潰す。それが、我々エンジニアが資産を守るための唯一の道だ。

現場からは以上だ。また次の脆弱性報告で会おう。

コメント

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