はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、ECDSA署名のy-parityパラメータ(v)を固定することで署名を96バイトから64バイトに圧縮し、Signature Malleabilityも排除する仕組みを提案しているERC8111についてまとめていきます!

以下にまとめられているものを解説しながらまとめていきます。

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

ERC8111とは、ECDSA署名のvパラメータを27に固定することで、署名を96バイトから64バイトに圧縮する仕組みのことです。

Ethereumで使われるECDSA署名はv、r、sの3つのパラメータで構成され、Solidity ABIエンコーディングでは96バイトを占めます。 しかしvの値は27か28の2通りしかなく、この1ビットの情報のために32バイトの領域が消費されていました。 ERC8111はvを常に27に固定し、必要に応じてsの値を変換することで、rとsの64バイトだけで署名を表現できるようにします。 さらにこの方式では、同じメッセージに対して複数の有効な署名が存在するSignature Malleability(署名展性)の問題も同時に排除されます。

ECDSA署名の基礎

ECDSA(Elliptic Curve Digital Signature Algorithm)は、楕円曲線暗号を用いたデジタル署名アルゴリズムです。 Ethereumではsecp256k1という楕円曲線を使用しており、署名は以下の3つのパラメータで構成されます。

secp256k1 は、Bitcoinの設計で採用されたのを機に広く普及した楕円曲線です。「sec」は標準化団体Standards for Efficient Cryptography、「p」は素体(prime field)、「256」はビット長、「k1」はKoblitz曲線の第1番という意味です。Ethereumもこの曲線を採用しており、秘密鍵・公開鍵・署名はすべてこの曲線上の数学に基づいています。

パラメータ サイズ 説明
r 32バイト 楕円曲線上の点のx座標。署名生成時にランダムに選ばれた点から計算される。
s 32バイト 署名のチャレンジ解。秘密鍵とメッセージのハッシュから計算される。
v 32バイト(ABI) y-parity(y座標の偶奇)を示す値。27または28。楕円曲線の対称性から、1つのx座標に対して2つのy座標が存在するため、どちらの解であるかを示す。

y-parity(y座標の偶奇) について補足します。楕円曲線上の点は (x, y) の座標で表されますが、同じ x 座標に対して y 座標が2つ存在します(曲線がx軸について対称なため)。v はそのどちらの y 座標を使うかを示す1ビットの情報で、偶数なら 27、奇数なら 28 となります。署名を検証する側はこの情報を使って公開鍵(アドレス)を一意に復元します。

Solidity ABIエンコーディングでは各パラメータが256ビット(32バイト)にアラインされるため、合計96バイトになります。 しかし、vの値は実質的に1ビットの情報しか持っていません。

Signature Malleability(署名展性)

署名展性とは、ある署名から別の有効な署名を計算できてしまう性質のことです。 ECDSAでは、署名(v, r, s)が有効な場合、(v', r, s')も有効な署名になります。 ここでv'はvを反転した値(27なら28、28なら27)、s'はsecp256k1の位数Nからsを引いた値です。

この性質が問題になるのは、署名のハッシュを使ってトランザクションの一意性を判定する場面です。 例えば、マルチシグウォレットで「この署名はすでに使用済みか」をチェックする時、攻撃者が署名を変換して別の有効な署名を作成すると、同じ承認を二重に使用される可能性があります。

具体的な攻撃シナリオ

コントラクトが「署名のハッシュ → 使用済みフラグ」をmappingで管理していると仮定します。攻撃者が元の署名 (v=27, r, s) のハッシュを使用済みにされた後、数学的に等価な署名 (v=28, r, N-s) を使って同じ承認を再送できます。両者はハッシュが異なるため、使用済みチェックをすり抜けてしまいます。これがSignature Malleabilityによる二重使用攻撃です。

動機

署名サイズとガスコストの問題

Ethereumのスマートコントラクトでは、ECDSA署名はcalldata(コントラクト関数を呼び出す時に一緒に送られるバイトデータ。引数の実体が格納される領域)として渡されます。 calldataの各バイトにはガスコストがかかるため、署名のサイズは直接コストに影響します。 96バイトの署名を64バイトに圧縮できれば、署名を受け取るすべてのコントラクトでガスコストを削減できます。

既存の圧縮方式の課題

署名を64バイトに圧縮する方式としては、すでにERC2098が存在します。 ERC2098はsの最上位ビット(MSB)にy-parityの値を格納するアプローチを取っています。 EIP2によりcanonical signature(正規化された署名。s の値がsecp256k1の位数 N の半分以下に制限されたもの)ではsの最上位ビットが常に0になるため、このビットをy-parityの格納に流用できます。

しかしERC2098には課題があります。 圧縮された署名をスマートコントラクトで使う時、ecrecoverプリコンパイルに渡す前に、最上位ビットからvを取り出し、sから最上位ビットをマスクする「アンパック処理」が必要です。 このアンパック処理にはガスが余分にかかります。

ecrecover プリコンパイル とは、Ethereumに組み込まれた特殊な低レベル関数です。通常のSolidityコードより高速かつ安価に実行できるようEVMに直接実装されており、アドレス 0x0000...0001 に配置されています。ecrecover(digest, v, r, s) を呼び出すと、署名を生成した秘密鍵に対応するアドレスを返します。Solidityの ecrecover() はこのプリコンパイルを呼び出す組み込み関数です。

以下の図は、ERC2098とERC8111の圧縮方式の違いを示しています。

ERC2098 vs ERC8111 署名圧縮方式の比較

Bound Signaturesの解決策

ERC8111のBound Signaturesは異なるアプローチを取ります。 vを常に27に固定し、元の署名がv = 28の場合はsをsecp256k1の位数Nから引いた値に変換します。 この方式では、圧縮された署名がそのままecrecover(digest, 27, r, s)の入力として使えるため、アンパック処理が不要です。 そのためERC2098と比べてガス効率が良くなります。

仕様

コントラクト側のルール

Bound Signaturesを受け付けるスマートコントラクトは、vとして27のみを使用します。 それ以外の値は受け付けてはいけません。

以下はBound Signatureの検証コードです。

javascript
address signer = ecrecover(digest, 27, r, s);
require(signer != address(0));

ecrecoverは署名が無効な場合にaddress(0)を返すため、この検証でゼロアドレスでないことを確認します。

この設計の重要な点は、vを引数として受け取らないことです。 従来のECDSA署名検証ではecrecover(digest, v, r, s)のようにvも受け取りますが、Bound Signaturesではvが常に27であるため、引数から省略できます。 これにより、calldataから32バイト分を削減できます。

vとして27と28の両方を許可すると、署名展性が再び発生してしまいます。 例えば、v = 27, s = Xの署名が有効な場合、v = 28, s = N - Xも有効な署名になります。 両方を受け付けると、1つのメッセージに対して2つの有効な署名が存在することになるため、27のみに限定する必要があります。

bind関数

ECDSA署名をBound Signatureに変換するbind関数は以下のように実装されます。

javascript
const SECP256K1_N: bigint = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n

function bind(sig: Signature, v: 27 | 28 = 27): Signature {
    if (sig.v === v) {
        return sig
    }
    const s = SECP256K1_N - sig.s
    return new Signature(sig.r, s, v)
}

提案はTypeScriptで記述されており、vの型は27 | 28の2値のみに制限されています。 デフォルト値は27ですが、引数で28を指定すればv = 28への変換も可能な汎用的な設計になっています。

この関数の動作は以下の通りです。 元の署名のvがすでに目標の値(デフォルトでは27)であれば、そのまま返します。 vが異なる場合は、sの値をsecp256k1曲線の位数Nから引いた値に変換し、目標のvを設定して返します。

SECP256K1_Nはsecp256k1楕円曲線の位数(order)で、sの値の取りうる範囲を定義する定数です。 楕円曲線の数学的性質により、sをN - sに変換すると、v(y-parity)が反転します。 そのためv = 28の署名をv = 27に変換できます。

位数(order) とは、楕円曲線上の点を何回足し合わせると単位元(無限遠点)に戻るかを表す数です。secp256k1の位数 N は約2の256乗の大きな素数で、s の値は必ず 1 以上 N-1 以下 の範囲に収まります。s と N-s は数学的に対称な関係にあり、一方から他方に変換しても署名の検証可能性(どの公開鍵で署名されたか)は変わりませんが、y-parityだけが反転します。この性質を利用して v の変換を実現しています。

以下のmermaid図はbind関数の処理フローを示しています。

flowchart TD
    A["署名(v, r, s)を受け取る"] --> B{"v == 27 ?"}
    B -- はい --> C["そのまま返す\n(v=27, r, s)"]
    B -- いいえ --> D["s' = N - s に変換"]
    D --> E["変換後の署名を返す\n(v=27, r, s')"]

元の署名のvが27ならそのまま使えます。 28の場合はsを反転させることで、ecrecoverがv=27で正しい署名者を復元できる形に変換します。

以下の図は、Bound Signature生成と検証の全体フローを示しています。

Bound Signature 生成と検証のフロー

署名検証のフロー

以下のシーケンス図は、Bound Signatureを使ったオンチェーン検証の流れを示しています。

sequenceDiagram
    participant Signer as 署名者(オフチェーン)
    participant Client as クライアント
    participant Contract as スマートコントラクト
    participant Precompile as ecrecover

    Signer->>Client: ECDSA署名(v, r, s)を生成
    Client->>Client: bind(sig)でv=27に変換
    Client->>Contract: (r, s)のみ送信(64バイト)
    Contract->>Precompile: ecrecover(digest, 27, r, s)
    Precompile-->>Contract: 署名者のアドレスを返却
    Contract->>Contract: アドレスの検証

署名者が通常のECDSA署名を生成し、クライアント側でbind関数を使ってv=27に変換します。 コントラクトにはrとsの64バイトだけを送信し、コントラクトは固定値の27と合わせてecrecoverを呼び出します。

補足

ERC2098との比較

ERC8111とERC2098はどちらもECDSA署名を64バイトに圧縮する方式ですが、アプローチが異なります。

項目 ERC2098 ERC8111
圧縮方式 sのMSBにy-parityを格納 vを27に固定し、sを調整
署名サイズ 64バイト 64バイト
ecrecoverとの互換性 アンパック処理が必要 そのまま入力可能
コントラクト側の処理 ビットシフトとマスク操作 不要(v=27を直接指定)
ガス効率 アンパック分のガスが追加 より効率的
Signature Malleability 排除される 排除される

ERC2098では、コントラクトが圧縮署名を受け取った後、yParityAndSから最上位ビットを取り出してvを復元し、残りの255ビットからsを復元するアンパック処理が必要です。 一方、ERC8111のBound Signaturesはecrecoverプリコンパイルにそのまま入力できるため、追加のガスコストが発生しません。

なぜ27を選んだか

vの固定値として27が選ばれた理由は、y-parityを「偽」(falsy)にするためです。 Ethereumではv = 27がy-parityの0(偶数)に、v = 28がy-parityの1(奇数)に対応します。 0は多くのプログラミング言語でfalsyな値であるため、直感的な選択といえます。

後方互換性

Bound Signaturesはecrecoverプリコンパイルと互換性があります。 vパラメータに27を指定すれば、従来のecrecoverでそのまま検証できます。

ただし、トランザクション署名には使用できないという制限があります。 EIP2により、Ethereumのトランザクション署名では「low s」が要求されます。 具体的には、sの値がsecp256k1の位数Nの半分以下でなければなりません。 Bound Signaturesではs = N - original_sのように変換するため、sがNの半分を超える「high s」になる可能性があります。 そのため、トランザクション署名ではなく、コントラクト内のカスタム署名検証にのみ使用できます。

具体的な用途としては、メタトランザクション(ガス代を別アドレスが肩代わりするため、ユーザーがETHを持たなくてもコントラクトを操作できる仕組み)、マルチシグウォレット、オフチェーン承認(permit。トークンのapproveをオンチェーントランザクションなしに署名だけで行う仕組み)、EIP712(構造化データに型情報を付与して署名する標準規格)の型付きデータ署名などがあります。

テストケース

提案では以下の3パターンのテストベクターが提供されています。 すべてv = 27でecrecoverを実行すると、正しい署名者アドレスが復元されます。

署名者 ダイジェスト r s
0x4a6f6B9fF1fc974096f9063a45Fd12bD5B928AD1 0xb0922c37cafd247fe3ada4eb1d1e3735b7d2837437c1178e9af120d535214270 0xdb7f75635124c807ec1f8b03e34cd76b633dc3a189e3c85fc5aee7e7d71df38c 0x5f1e6c6edf21cacfc2acba2815b253b9048b894eec5aaf70343389bb596c48bc
0x4a6f6B9fF1fc974096f9063a45Fd12bD5B928AD1 0xd92ff06caae7253883627416a425414d79e9003b91d6208add30e73735ef13c3 0xaa40efd534ac7f96b85babd7df9228fa131e8523115ca1ebc025698c37f3867d 0xa4b8d3c650fe62e46a563aed681bfdd44d50452fa7712bd139f2bfba3aed59c9
0x6B93E3bB9C0780C0f9042346Ffc379530a5882c1 0xfa75eba87f076cf22489da7c53a651bb3869473f78d09d4814afb7ab2d54ed45 0xaf4a877600ab6d14ebac626830cf1063d624487932b3cc73a7cd98ae7fbf337f 0xbc41d29acfcd3a1e7b5cb2dde1b85fe8882739312639b5f16d476a87584c040f

これらのテストベクターは、Bound Signatureがecrecoverで正しく署名者を復元できることを検証するためのものです。 Bound Signaturesではsの値がsecp256k1の位数Nの半分を超える場合(high s)がありえますが、ecrecoverプリコンパイル自体はhigh sでも問題なく動作します。 ただし、前述の通りトランザクション署名ではEIP2によりhigh sは無効とされるため、この使い分けに注意が必要です。

参考実装

以下はBound Signaturesの検証を行うSolidityライブラリの参考実装です。

contracts/BoundSignatures.sol
pragma solidity ^0.8.30;

library BoundSignatures {
    function recover(bytes32 digest, bytes32 r, bytes32 s) internal pure returns (address signer) {
        signer = ecrecover(digest, 27, r, s);
        require(signer != address(0));
    }
}

このrecover関数はvパラメータを受け取らず、内部で固定値27を使用します。 従来の署名検証関数と比べて、引数が1つ少なくなっています。

ecrecoverがゼロアドレスを返した場合は署名が無効であることを意味するため、requireで検証しています。 このライブラリを使うことで、Bound Signatureの検証を安全かつ簡潔に行えます。

使用例

このライブラリを使ってメッセージの署名者を検証するコントラクトの例を示します。

contracts/Example.sol
pragma solidity ^0.8.30;

import "./BoundSignatures.sol";

contract SignatureVerifier {
    using BoundSignatures for bytes32;

    mapping(bytes32 => bool) public usedSignatures;

    function verify(bytes32 digest, bytes32 r, bytes32 s) external view returns (address) {
        return digest.recover(r, s);
    }

    function executeWithSignature(
        bytes32 digest,
        bytes32 r,
        bytes32 s,
        address expectedSigner
    ) external {
        bytes32 sigHash = keccak256(abi.encodePacked(r, s));
        require(!usedSignatures[sigHash], "Signature already used");

        address signer = digest.recover(r, s);
        require(signer == expectedSigner, "Invalid signer");

        usedSignatures[sigHash] = true;
    }
}

executeWithSignature関数では、署名のハッシュを使って二重使用を防止しています。 Bound Signaturesでは1つのメッセージに対して有効な署名が1つだけなので、rとsのハッシュだけで一意に特定できます。 Signature Malleabilityが排除されているため、攻撃者が署名を変形させて二重使用チェックを回避することはできません。

セキュリティ

署名の復元可能性

Bound Signaturesは復元可能な署名です。 ecrecoverを使って署名者の公開鍵(アドレス)を復元できるため、従来のECDSA署名と同じセキュリティ特性を持ちます。

Signature Malleabilityの排除

Bound Signaturesではvが27に固定されているため、署名展性が排除されています。 従来のECDSA署名では(27, r, s)と(28, r, N - s)の2つの有効な署名が存在しましたが、v = 27のみを受け付けることで、各ダイジェストに対して有効な署名は1つだけになります。 これにより、署名のハッシュをキーとしてリプレイ攻撃(一度使用した署名を再利用して同じ操作を繰り返す攻撃)を防止するパターンが安全に使えます。

引用

最後に

今回は「ERC8111によるECDSA署名の64バイト圧縮とSignature Malleabilityの排除」についてまとめてきました。 いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!