Ethereumのオフチェーン署名について
概要
スマートコントラクト開発で避けて通れない道、オフチェーン署名。
この記事ではEthereumのオフチェーン署名について網羅的に解説していきます。特にメタトランザクションへ対応した、署名検証でアドレスを取得するコントラクトの作成などを目指している人や、msg.senderでアドレスを取得する関数を定義している人は是非本記事が参考になると思います。間違っている点があれば是非コメントでご指摘ください。
前提知識
- Ethereum
- EIP(Ethereum Improvement Proposals)
- Solidity
- ECDSA署名
※本文ではトランザクションをTxと略します。
オフチェーン署名とは
オフチェーン署名とは、コントラクトに渡されるEOAやCA(Contract Account)により生成された署名のことです。現在多くのコントラクトは、ecrecover関数などを使用してECDSAからアドレスを抽出し、ステートを更新するような処理を行なっています。従来のmsg.senderでの実装はメタトランザクションを行うことができません。
ここまで読んで「CAは秘密鍵を持たないから署名ができないのでは?」と思われた方もいると思いますが、これについてはEIP1271で解説していきますので安心してください。
ERC-191 (Signed Data Standard)
これはコントラクトが署名を処理できるように定義された初の規格(ERC)です。この規格を提案するに至った理由は大きく二つ存在しています。一点は従来の署名が標準のEthereum Txで使われる署名であった為、それを流用し再度Txを発行することでリプレイ攻撃が可能になってしまう点です。もう一点はマルチシグウォレットなどに署名を提供する場合、どのウォレットに対しての署名かのコンテキストが欠如してしまっている為、特定のウォレットに対して行なったはずの署名が、意図しないウォレットで利用されてしまう問題です。
提案書: EIP-191
signed_data
これら問題を防ぐ為に以下のようなsigned_data(署名されたデータ)の形式を提案されました。そして署名者はこれらsigned_dataと秘密鍵を用いて署名ハッシュを生成します。
0x19 <1 byte version> <version specific data> <data to sign>.
0x19: 生成する署名がRLPというスキームでバイト列へエンコーディングできないようにする為、この値を挿入します。これはRLPでエンコード可能な場合、署名を標準のEthereum Txでも利用することができてしまうからです。またEIP191に準拠していることを明示する役割も果たしています。
<1byte version>: ここにはどのバージョンに準拠した署名かが明記されます。意図したコントラクトなどが検証できる署名の規格(0x00
)や、後々説明するEIP712に準拠した署名(0x01
)など、特定のバージョンをここで明記します。これ以降はこのパラメータをVersion Prefixと呼びます。
<version specific data>: 署名のバージョンごとに固有の必要とするデータが存在します。0x00
では許可されたバリデータ(署名の検証者)のアドレスを挿入、0x01
では後々解説するEIP712に準拠したデータの規格が入ります。
<data to sign>: ここには署名する内容(DApp側で定義された任意のデータ)が入ってきます。
personal_sign(JSON-RPC)
現在最も多くのEOA Walletが生成することのできるEIP191準拠の署名はpersonal_signというメソッドを使用して生成されたものになります。
personal_signで署名を生成する場合は0x45
というVersion Prefixを指定する必要があります。またバージョン固有のデータをパラメータとして挿入する必要があります。
0x19 <0x45 (E)> <"thereum Signed Message:\n" + len(message)> <data to sign>
personal_signで署名を生成する場合、パラメータはこのようになります。0x45はUnicodeだとEの文字が割り当てられているので、このようなユニークなパラメータとなっています。誤字ではありません...
ecrecover関数
生成された署名ハッシュはコントラクトへ提供されます。そこでコントラクトはecrecover関数を実装し、これらを検証する必要があります。
ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address)
EIP-712 (Typed structured data hashing and signing)
このEIP712はデータ型と構造化された署名データの規格やハッシュ化についてEIP191を拡張する形で定義されています。提案のモチベーションはEIP191がユーザーフレンドリではなかった為です。personal_signにより生成される署名のメッセージはユーザが理解困難でした。この提案により従来の標準的なTxへの署名
やEIP191を代表とするバイト列への署名
に型付きの構造体データ
を加える事となりました。
提案書: EIP-712
仕様
型付け構造化データにはhashStruct(message)
とdomainSeparator
の二種類が存在します。domainSeparatorはDAppのコンテキスト、messageはそのTXのコンテキストをそれぞれ表します。これらはSolidityのstructと互換性があり、これにより簡単に構造体をコントラクト側で検証できます。
hashStruct関数
複雑である型付け構造化データを適切にHash化する為に用いられる関数です。引数にはs : 𝕊
があり、それぞれ値が定義された構造体
と型が宣言された構造体
を指します。
hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s))
typeHash = keccak256(encodeType(typeOf(s)))
hashStruct(message)
これは署名されるメッセージの型付け構造化データです。TXのコンテキストが格納されます。実行する関数のシグネチャと引数の値をMessageとして用いる設計がスタンダードであると思われます。
struct Mail {
address from;
address to;
string contents;
}
domainSeparator <hashStruct(eip712Domain)>
どのDAppに対しての署名かをeip712Domainという構造体で定義しなければいけません。これは以下のようなプロパティを含みます。
- name
- version
- chainId
- veryfyingContract
- salt(option)
これら構造体をHash化したものをdomainSeparatorと呼び、後にこれはhashStruct(message)と連結されます。
domainSeparator = hashStruct(eip712Domain)
eth_signTypedData_v4 (JSON-RPC)
この構造化データで署名を生成する場合に用いられるJSON-RPCメソッドです。eth_signTypedData_v4で、パラメータへ0x19
, 0x01(EIP712を表す)のVersion Prefix
, EIP712固有の構造体
を用いハッシュ化し、秘密鍵で署名を生成できます。
sign(keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)))
ERC-1271 (Standard Signature Validation Method for Contracts)
今まで解説してきたのはEOAの生成するECDSAの形式を定義したEIPでした。このような署名は秘密鍵を持つEOAのみしか生成することができず、Contract Accountはできません。このEIP1271では、独自の署名ロジックを定義し、そのロジックに基づいて生成された署名か検証することのできるコントラクトの規格を提案しました。これにより定義された独自のロジックに基づく署名を生成することができれば、EOA,CA(Contract Account)関係なく署名を行うことができます。この規格を利用してMulti Signatureを実装したgenosis safeというSCA(Smart Contract Account)などが作成されています。
提案書: EIP-1271
isValidSignature関数
独自の署名ロジックでの検証を行う場合、このisValidSignatureをコントラクトに実装する必要があります。このisValidSignatureという名前が規格化されており、関数のロジックはユーザ定義です。
function isValidSignature(
bytes32 _hash,
bytes memory _signature)
public
view
returns (bytes4 magicValue);
戻り値であるmagicValueは署名がtrueであれば0x1626ba7e
を、falseならば0xffffffff
が挿入されるよう定義する必要があります。
Discussion