本文へスキップ
ecdsa.com
← すべての記事

ECDSA 公開鍵の復元:リカバリ ID、v、ecrecover

ECDSA 署名に 2 ビットを加えるだけで、署名者の公開鍵を計算できます。Ethereum のトランザクションが鍵をまったく含まないのはこのためです。この記事では Python で鍵を手作業で復元し、その結果を cast、ecrecover プリコンパイル、Bitcoin Core 31.1、viem、noble と照合します。

要点:ECDSA 署名 (r, s) とメッセージハッシュ e から、署名者の公開鍵は Q = r⁻¹(s·R − e·G) として求められます。ここで R は、x 座標が r である曲線上の点です。そのような点は最大 4 つあるため、署名者はリカバリ ID を添えます。これは R の y 座標のパリティを表す 1 ビットと、理論上は R.x が n で剰余を取られたかどうかを表すもう 1 ビットからなります。Ethereum はこれを v としてエンコードし(メッセージでは 27/28、レガシートランザクションでは chainId·2 + 35 + yParity、型付きトランザクションでは yParity)、Bitcoin は 65 バイトのコンパクト署名の先頭にあるヘッダーバイト(27〜42)としてエンコードします。形式が正しい入力に対して、復元は必ず何らかの鍵を返します。結果を想定していたアドレスや鍵と比較して初めて検証になります。Ethereum の ecrecover プリコンパイルは、不正な形式の入力に対しては何も返さず、Solidity はそれを address(0) に変換します。また、このプリコンパイルは s と n − s の両方を受け入れます。

数式

ECDSA の署名では、nonce k を選んで R = kG を計算し、r = R.x mod n、s = k⁻¹(e + r·d) mod n とします。Q = dG を使って 2 番目の式を変形すると、次のようになります。

s·k = e + r·d → s·R = e·G + r·Q → Q = r⁻¹(s·R − e·G)。

右辺で未知なのは R そのものだけですが、署名に含まれているのは、n で剰余を取った R の x 座標だけです。R を再構成するために、検証者には次の情報が必要です。

  • R.y のパリティ:ある x に対して点は (x, y) と (x, p − y) の 2 つがあり、そのうち一方の y が偶数です。
  • R.x ≥ n かどうか:secp256k1 では有限体の素数 p が群の位数 n よりわずかに大きいため、[n, p) の範囲にある x 座標は n で剰余を取られているはずです。その確率は (p − n)/p、約 2⁻¹²⁷ で、実際には起こりません。Ethereum の v ではそもそも表現できません。

これがリカバリ ID で、recid = 2·[R.x ≥ n] + (R.y mod 2) と定義され、SEC 1 v2.0 §4.1.6 で規定されています(SEC 1)。何も省略せずに Python で書くと次のとおりです。

recover.py(抜粋):復元、Python 3.14.8(曲線演算は secp256k1-explained と同じ)
def recover(e: int, r: int, s: int, recid: int):
    """Return the public key Q with s = k^-1 (e + r*d), or None if recid does not name a curve point."""
    if not (0 < r < n and 0 < s < n):
        raise ValueError("r and s must be in [1, n-1]")
    x = r + (recid >> 1) * n          # recid 2 and 3: R.x was >= n and got reduced
    if x >= p:
        return None
    alpha = (x * x * x + 7) % p
    y = pow(alpha, (p + 1) // 4, p)   # p % 4 == 3, so this is a square root if one exists
    if y * y % p != alpha:
        return None                   # r is not the x-coordinate of any point
    if y & 1 != recid & 1:
        y = p - y                     # recid bit 0 is the parity of R.y
    R = (x, y)
    r_inv = pow(r, -1, n)
    return add(mul(s * r_inv % n, R), mul(-e * r_inv % n, G))   # Q = r^-1 (sR - eG)


def eth_address(Q):
    k = keccak.new(digest_bits=256, data=Q[0].to_bytes(32, "big") + Q[1].to_bytes(32, "big"))
    return "0x" + k.hexdigest()[-40:]


def eth_personal_digest(message: bytes) -> int:
    data = b"\x19Ethereum Signed Message:\n" + str(len(message)).encode() + message
    return int.from_bytes(keccak.new(digest_bits=256, data=data).digest(), "big")

定数と平方根を求める簡便法を除けば、ここに secp256k1 固有の部分はなく、復元は P-256 でも機能します。公開鍵の復元は曲線の性質ではなく Bitcoin と Ethereum のエコシステムの慣習であり、JOSE、TLS、WebAuthn では代わりに鍵そのものを送信するか、証明書で鍵を証明します。

Ethereum:cast で作成した署名を手作業で復元

実験環境では Foundry 1.8.5 で使い捨ての鍵を生成し、personal_sign の方式(EIP-191)でメッセージに署名しました。

eth.sh(抜粋):署名し、cast で検証し、Python で復元
KEY=$(cast wallet new --json | python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['private_key'])")
ADDR=$(cast wallet address --private-key "$KEY")
SIG=$(cast wallet sign --private-key "$KEY" "hello from ecdsa.com")
cast wallet verify --address "$ADDR" "hello from ecdsa.com" "$SIG"
python3 eth_recover.py "hello from ecdsa.com" "$SIG" "$ADDR"

eth_recover.py は 65 バイトを r、s、v に分割し、4 つのリカバリ ID をすべて試してから、同じ署名をわずかに異なるメッセージに対して復元します。

出力(cast 1.8.5、pycryptodome 3.23.0)
address: 0x22baE48F42D493eDcF82ddB7e47480B7233917b3
signature: 0x150e0d6f3cc85f603300fe1801bc4397021a51495c53186f15ecb05f9e57dcbe2a17a5521f6c42792fc22a98754e5c01dd26ac42009e8d24d3cb713a8fe96fed1c
Validation succeeded. Address 0x22baE48F42D493eDcF82ddB7e47480B7233917b3 signed this message.
r=0x150e0d6f3cc85f603300fe1801bc4397021a51495c53186f15ecb05f9e57dcbe
s=0x2a17a5521f6c42792fc22a98754e5c01dd26ac42009e8d24d3cb713a8fe96fed
v=28  (s is low)
recid 0: 0xb1775d58a680572a45fe5d6ba094c56b0b748c9d
recid 1: 0x22bae48f42d493edcf82ddb7e47480b7233917b3  <- v - 27
recid 2: no point (x = r + n is not below p)
recid 3: no point (x = r + n is not below p)
matches the signer: True
same signature, other message -> 0x994ba1aaf9e6b96710727d919140aff23f56407c

ここから読み取れることは 2 つあります。誤ったリカバリ ID はエラーではなく別の有効な鍵を返すため、v を推測する検証者は何事もなく別人の鍵を「復元」してしまいます。また、別のメッセージ(ecdsa.com ではなく ecdsa.org)に対する同じ署名からは、さらに別の、形式としては正しいアドレスが復元されます。誤ったデータに対して復元が失敗することはなく、想定する署名者と比較して初めてチェックになります。

ecrecover が返すもの

コントラクトは、アドレス 0x01 のプリコンパイルを 128 バイトの入力で呼び出します。入力はハッシュと、それに続く 32 バイトのワードとしての v、r、s です。実験環境では、ローカルの anvil ノード上でこのプリコンパイルに cast call で 5 つの入力を送りました。

cast call 0x0000000000000000000000000000000000000001 <ハッシュ ‖ v ‖ r ‖ s>(anvil 1.8.5)
as signed (v, r, s)                0x00000000000000000000000022bae48f42d493edcf82ddb7e47480b7233917b3
twin (55-v, r, n-s)                0x00000000000000000000000022bae48f42d493edcf82ddb7e47480b7233917b3
v = 29                             0x
s + 1                              0x000000000000000000000000412fbd6e833de18577db8f41022c54e852192b34
s = 0                              0x
  • ツイン署名(v を 27 と 28 の間で入れ替え、s を n − s に置き換えたもの)からは、同じ署名者が復元されます。EIP-2 は high-S のトランザクション署名を無効にしましたが、プリコンパイルには手を加えず、プリコンパイルが「high-S の値を引き続き受け入れる」ことを明記しています。署名のバイト列を識別子として使うコントラクト、たとえば署名済みのバウチャーを使用済みとして記録するコントラクトは、ツイン署名によってリプレイされる可能性があります。使用済みとして記録するのはメッセージ(nonce またはダイジェスト)にし、署名を記録してはいけません。
  • 不正な形式の入力(27/28 以外の v、s = 0)では出力が空になります。Solidity の ecrecover はこれを address(0) に変換し、ドキュメントにも「エラー時にはゼロを返す」と書かれています。想定する署名者が address(0) になりうる場合(未設定のオーナー、mapping に存在しないエントリ)、無効な署名がすべて通ってしまいます。
  • 変更した s からは、Python でのテストと同じく、ランダムに見えるアドレスが復元されます。

この 3 つすべてに対する一般的な対策は、OpenZeppelin の ECDSA.recover(Contracts 5.6.1)です。n/2 を超える s を ECDSAInvalidSignatureS で、ゼロの結果を ECDSAInvalidSignature で拒否し、bytes を受け取るオーバーロードでは 65 バイト以外の入力をすべて拒否します。スマートコントラクトウォレットにはそもそも復元すべき鍵がなく、Sign-In with Ethereum で説明しているとおり、ERC-1271 で検証します。

v のさまざまな形

リカバリビットがどこに置かれるかは、署名の対象によって異なります。

用途エンコーディング例
personal_sign、EIP-712、ほとんどのウォレット65 バイトの r ‖ s ‖ v、v = 27 + yParityv = 27 または 28
レガシートランザクション、EIP-155v = chainId·2 + 35 + yParityチェーン 1:37 または 38
型付きトランザクション(EIP-2930、EIP-1559、EIP-4844、EIP-7702)独立した yParity フィールド、0 または 10 または 1
EIP-2098 のコンパクト署名64 バイトの r ‖ yParityAndS、パリティは s の最上位ビット最上位ビットが 1 なら yParity 1
Bitcoin のメッセージ署名(BIP-137)65 バイトの header ‖ r ‖ s、header = 27 + recid + 0(非圧縮形式の P2PKH)、4(圧縮形式の P2PKH)、8(P2SH-P2WPKH)、12(P2WPKH)のいずれか圧縮形式の P2PKH 鍵では 31 または 32
低レベルのライブラリ(libsecp256k1、noble)リカバリ ID(0〜3)を別に渡す0 または 1

実験環境では viem 2.57.7 で署名し、その復元関数が各バリエーションをどう扱うかを確認しました。

rec.mjs(抜粋):viem 2.57.7、@noble/curves 2.4.0
for (const [label, sig] of [
  ["65-byte", signature],
  ["64-byte compact", compact],
  ["63 bytes", signature.slice(0, -4)],
  ["v = 29", signature.slice(0, -2) + "1d"],
]) {
  try {
    const who = await recoverMessageAddress({ message, signature: sig });
    console.log(`recover ${label.padEnd(16)} ${who === account.address ? "signer" : who}`);
  } catch (e) {
    console.log(`recover ${label.padEnd(16)} ${e.shortMessage ?? e.message.split("\n")[0]}`);
  }
}

// The same recovery with noble: the recovery bit travels separately from the 64-byte (r, s).
const digest = hashMessage(message, "bytes");
const rs = Buffer.from(signature.slice(2, 130), "hex");
const pub = secp256k1.Signature.fromBytes(rs, "compact").addRecoveryBit(parsed.yParity).recoverPublicKey(digest);
console.log(`noble recovers    ${pub.toHex(true)} (compressed public key)`);
出力(node:24.22.0-bookworm-slim)
signer            0xa9650f6049917850af00B8e7Ffc386d9A3e18C08
signature         65 bytes, v=27, yParity=0
EIP-2098 compact  64 bytes, top bit of byte 32 = yParity: true
recover 65-byte          signer
recover 64-byte compact  invalid signature length
recover 63 bytes         invalid signature length
recover v = 29           Invalid yParityOrV value
noble recovers    036a079737b9587224344944600570a311f21b3b06f5c920b24bcd95e993afd7e0 (compressed public key)
legacy tx, chain 1: v=38 (yParity 1)
EIP-1559 tx, chain 1: v=28, yParity=1

viem の recoverMessageAddress は、64 バイトの EIP-2098 形式の文字列を受け付けません。先に compactSignatureToSignature で変換しないと、invalid signature length になります。範囲外の v では Invalid yParityOrV value になります。レガシートランザクションの v = 38 は 1·2 + 35 + 1 です。EIP-1559 トランザクションについて viem は便宜上 v = 28 と表示しますが、署名済みトランザクション自体に含まれるのは yParity だけです。Python の web3.py も長さには同じく厳格で、Expected 65, but got 64 bytes になります。

この実験環境を作る過程で、実際にバグを踏みました。27 は奇数なので、v ^ 1 でパリティを反転すると 27 が 26 に、28 が 29 になってしまいます。内部では yParity = v − 27(EIP-155 では (v − 35) mod 2)で扱い、境界で元の形式に戻してください。

Bitcoin:メッセージのコンパクト署名

Bitcoin Core の signmessage は、先頭にヘッダーバイトを付けた同じ種類の署名を生成します。regtest で使い捨ての鍵を使って試します。

btc.sh(抜粋):Bitcoin Core 31.1
SIG=$($CLI signmessagewithprivkey "$WIF" "$MSG")
$CLI verifymessage "$ADDR" "$SIG" "$MSG"
python3 btc_recover.py "$MSG" "$SIG"      # recover by hand, then build three variants
出力(bitcoin/bitcoin:31.1)
address: mvzrMJdGFmYPJw79zHz5i2WqMHdwchtbr1
signature: H6uRgJJnTugkk5XeUQl+eE1oY5/L6yZZORJHwTn1ohJdFOWEPPUvL6oHzDx2lAeoI+l1d3ipt5sw3TmF/tkpz60=
verifymessage: true
65 bytes: header 31 -> recid 0, compressed key: True, s is low
recovered public key: 03b7b74bd1e7c935c284dbb71b48649f1ff6a5cb939d615061204271a61f4232e1
its regtest P2PKH address: mvzrMJdGFmYPJw79zHz5i2WqMHdwchtbr1
other recid (header 32)              verifymessage: false
uncompressed header (27)             verifymessage: false
high-S twin (header 32, n - s)       verifymessage: true

ダイジェストは、"\x18Bitcoin Signed Message:\n"、メッセージ長、メッセージを連結したものに対する二重 SHA-256 です。ヘッダー 31 は、リカバリ ID 0 と圧縮形式の鍵を表します。これを 27 に変えると同じ点が復元されますが、非圧縮形式のエンコーディングがハッシュされて別のアドレスになるため、検証は失敗します。ecrecover と同じく、Bitcoin Core のメッセージ検証も high-S のツイン署名を受け入れました。low-S はトランザクションのリレールールであり、メッセージのルールではないからです。SegWit と Taproot のアドレスには、この形式では対応しきれません。ウォレット間で解釈が分かれるヘッダーの方式と新しい形式については、Bitcoin のメッセージ署名と BIP-322 で解説しています。

HSM と KMS の署名にリカバリ ID を付ける

ハードウェアセキュリティモジュールやクラウドの KMS はダイジェストに署名し、DER でエンコードした (r, s) を返します。リカバリ ID は含まれず、low-S である保証もありません。このような鍵を Ethereum で使うには、まず公開鍵からアドレスを一度だけ導出し、署名のたびに s を正規化して、自分の鍵が復元されるパリティを探します。実験環境では、事前ハッシュ済みのダイジェストにしか署名しない pyca/cryptography の鍵で HSM を模擬しています。

der_to_eth.py(cryptography 49.0.0)
"""Turn a DER ECDSA signature without a recovery id (what HSMs and cloud KMS return) into an Ethereum signature."""
import sys

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import Prehashed, decode_dss_signature

from recover import eth_address, eth_personal_digest, n, recover

# Stand-in for the HSM: a secp256k1 key that signs a 32-byte digest and returns DER, nothing else.
hsm_key = ec.generate_private_key(ec.SECP256K1())
public = hsm_key.public_key().public_numbers()
expected = eth_address((public.x, public.y))     # what you would derive once from GetPublicKey


def hsm_sign_digest(digest: bytes) -> bytes:
    return hsm_key.sign(digest, ec.ECDSA(Prehashed(hashes.SHA256())))   # any 32-byte digest


message = sys.argv[1].encode()
e = eth_personal_digest(message)
der = hsm_sign_digest(e.to_bytes(32, "big"))
r, s = decode_dss_signature(der)
print(f"DER signature: {len(der)} bytes, s is {'high' if s > n // 2 else 'low'}")
if s > n // 2:
    s = n - s                                     # EIP-2: transactions need low-S; normalize everything
for recid in (0, 1):                              # find the parity that recovers our own key
    Q = recover(e, r, s, recid)
    if Q and eth_address(Q) == expected:
        break
else:
    raise SystemExit("neither recovery id matches the key")
sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") + bytes([27 + recid])
print(f"address {expected}\nrecid {recid}\nsignature 0x{sig.hex()}")
python3 der_to_eth.py 'invoice 1042 approved' の後に cast wallet verify(3 回の実行のうち 1 回目)
DER signature: 71 bytes, s is high
address 0x3bf48296556c8b52217d22066c7239c50f2102e6
recid 1
signature 0x052fbd197e707e99a4c38648b4a65fb674b15c93025878c55b3950f876bb5cf634aa76d1ffe274696a3d94ba225ae72ab73af4292a0681dc7262dc8ef9495d071c
Validation succeeded. Address 0x3bf48296556c8B52217D22066C7239C50F2102E6 signed this message.

Prehashed(hashes.SHA256()) ラッパーは、入力がすでに 32 バイトのダイジェストであることをライブラリに伝えるだけで、ダイジェスト自体は Ethereum の要件どおり Keccak-256 です。ここで両方のパリティを試しても安全なのは、結果を既知の 1 つのアドレスと比較しているからです。DER の長さが、この実行では 71 バイト、別の実行では 72 バイトだった点に注意してください。DER は先頭のゼロバイトを取り除き、最上位ビットが立っているときはゼロバイトを 1 つ追加します。固定長のバッファーが壊れるのはこのためです(ECDSA 署名の形式)。同じ手順は AWS KMS の ECC_SECG_P256K1 鍵にも当てはまります。Google Cloud KMS は secp256k1 ではすでに low-S の署名を返しますが、やはりリカバリ ID は含まれません。

復元を使うべき場面

復元を使うと署名 1 つあたり 33〜65 バイトを節約できるため、Ethereum はすべてのトランザクションでこれを使っています。同時に、署名が証明する内容も変わります。鍵を送信する方式では、検証者は「この鍵がこのメッセージに署名した」ことを確認します。復元では「何らかの鍵がこのメッセージに署名した」ことを計算し、その鍵を受け入れてよいかを別途判断しなければなりません。通常はアドレスを導出し、許可リスト、保存済みのオーナー、またはユーザーが主張する from と比較します。この比較を省略したコードは、何も検証していません。

ブロックチェーン以外で復元を使う理由は、ほとんどありません。鍵 ID や証明書を運べるプロトコル(JWT の kid、TLS、X.509)ではそれらを使うべきです。そうすれば検証者は、数式が出力した鍵をそのまま信頼するのではなく、ちょうど 1 つの鍵を参照します。

よくある間違い

  • 比較せずに復元する:ecrecover が成功しただけで「署名は有効」とログに記録するケースです。形式が正しい署名からは、必ず何らかの鍵が復元されます。
  • address(0) を署名者として受け入れる:直接、あるいは初期化されていない変数を介して受け入れてしまうケースです。
  • 署名のバイト列によるリプレイ保護:high-S のツイン署名は、同じメッセージに対するもう 1 つの、同じく有効な署名です。
  • v を推測する:両方のパリティを試し、「何らかの」鍵が復元された方を受け入れると、検証はどんな鍵でもよい検索に変わってしまいます。両方を試すのは、その後で想定する 1 つのアドレスと比較する場合だけにしてください。
  • 誤った対象をハッシュする:personal_sign は \x19Ethereum Signed Message:\n というプレフィックスと 10 進数の長さを付加し、EIP-712 は \x19\x01 と 2 つの構造体ハッシュを使います。誤ったダイジェストで復元すると、エラーなしで誤ったアドレスが得られます。違いは EIP-191 と EIP-712 の比較で解説しています。
  • 形式の不一致:65 バイトの署名、64 バイトの EIP-2098 署名、DER 署名の間で形式が食い違うケースです。境界で明示的に変換し、HSM や KMS の出力には前述のとおりリカバリ ID を付加してください。

出典