本文へスキップ
ecdsa.com

BIP-137 と BIP-322 の比較|Bitcoin のメッセージ署名

BIP-137 は単一鍵アドレスの背後にある鍵で署名し、検証者がその鍵を復元できるようにします。BIP-322 は、決してブロードキャストできないトランザクションに署名することで、任意のアドレスから資金を使用できることを証明します。

BIP-137 と BIP-322 のどちらを使うかは、メッセージへの署名によってアドレスの管理権を証明する、Bitcoin の新旧 2 つの方法のどちらを選ぶかという問題です。BIP-137 は、古くから使われている「Bitcoin Signed Message」形式を文書化したものです。これは公開鍵を復元できる 65 バイトの ECDSA 署名で、そのヘッダーバイトが、復元した鍵からどのアドレスタイプを導出するかを示します。BIP-322 は、署名を任意のスクリプトに一般化します。メッセージは仮想トランザクションの中にコミットされ、署名はそのアドレスから資金を使用するためのウィットネスそのもので、通常のスクリプトインタープリターで検証されます。

比較表

BIP-137BIP-322
ステータスDeployed(運用中。2019年に番号を割り当て、形式自体ははるかに古い)2026年4月15日のバージョン 1.0.0 以降 Complete。2026年6月4日に 2.0.0
メッセージハッシュ"\x18Bitcoin Signed Message:\n"、長さ、メッセージに対するダブル SHA-256タグ BIP0322-signed-message を使う BIP-340 のタグ付き SHA-256
署名の対象メッセージハッシュそのもの仮想の to_spend 出力を使用する仮想の to_sign トランザクション
アドレスタイプP2PKH。ヘッダーの範囲によって P2SH-P2WPKH と P2WPKH任意のスクリプト:P2PKH、P2WPKH、P2WSH、P2TR、マルチシグ、タイムロック
検証者に必要なもの署名、メッセージ、アドレス。鍵は復元される署名、メッセージ、アドレス。スクリプトインタープリター
署名の内容ヘッダーバイト(27〜42)、r、sウィットネススタック(smp)、トランザクション全体(ful)、または PSBT(pof)
サイズ(base64)88 文字P2WPKH で 147、P2TR で 88、3-of-3 の P2WSH マルチシグで 439(テストベクトル)
資金証明不可可能(PSBT に追加の入力を含める、pof)
公開鍵へのコミットなし(復元される鍵を、関連する別の鍵にずらすことができる)あり(満たすべきスクリプトを通じて)
ハードウェアウォレットでの署名メッセージ署名モード(対応している場合)PSBT 形式のトランザクション署名

BIP-137 の仕組み

署名者は固定のプレフィックスを付けてメッセージをハッシュし、ECDSA で署名して、リカバリ ID をヘッダーバイトに格納します。ヘッダーバイトの値は、非圧縮鍵の P2PKH なら 27〜30、圧縮鍵なら 31〜34、P2SH-P2WPKH なら 35〜38、ネイティブ SegWit の P2WPKH なら 39〜42 です。検証者は公開鍵の復元によって署名から鍵を計算し、そのハッシュがアドレスと一致することを確認します。

この方法が使えるのは、アドレスが単一の公開鍵のハッシュである場合だけです。Taproot アドレスは tweak された鍵にコミットし、鍵の復元ができない Schnorr 署名を使います。マルチシグやその他のスクリプトには、そもそも単一の鍵がありません。SegWit 用のヘッダー範囲は後から追加されたもので、すべてのウォレットが採用したわけではありません。そのため、あるウォレットで作った署名が別のウォレットでは検証に失敗することがあり、Bitcoin Core 自身の signmessage と verifymessage は今でもレガシーの P2PKH アドレスしか受け付けません。

BIP-322 の仕組み

BIP-322 は、メッセージをタグ付きハッシュでハッシュし、その値を仮想トランザクション to_spend の入力スクリプトに入れます。このトランザクションの唯一の出力には、そのアドレスの scriptPubKey が設定されています。2 つ目のトランザクション to_sign は、その出力を使用して OP_RETURN に送ります。署名者は、そのアドレスから資金を使用するときにウォレットが作るのと同じウィットネスを作成し、検証者は両方のトランザクションを再構築してスクリプトインタープリターを実行します。to_spend は存在しない入力を使用するため、どちらのトランザクションもネットワーク上で有効になることはありません。

シンプル形式(smp)には、ウィットネススタックだけが含まれます。フル形式(ful)には to_sign トランザクション全体が含まれ、タイムロックのあるスクリプトやウィットネス以外の入力にはこの形式が必要です。その場合、検証者は証明が有効になる時刻と経過時間を報告します。資金証明(pof)は、実際の UTXO も使用するファイナライズ済みの PSBT で、署名によって特定のコインの管理権を示せます。レガシーの BIP-137 形式は、P2PKH アドレスに限って引き続き使用できます。

BIP-322 のテストベクトルで両形式を確認する

以下のスクリプトは、BIP-322 の基本テストベクトルにある秘密鍵(WIF 形式)とアドレスを使います。この鍵はテスト用に公開されているため、誰でもそのアドレスから資金を使用できます。このアドレスに決して資金を置いてはいけません。スクリプトは「Hello World」に対する BIP-137 署名を作成して検証し、BIP-322 のメッセージハッシュと 2 つの仮想トランザクションの ID を再構築し、テストベクトルで公開されている smp 署名を BIP-143 の署名ハッシュで検証します(Node.js 26、@noble/curves 2.3.0、@noble/hashes 2.3.0、@scure/base 2.4.0)。

1 つの P2WPKH アドレスに対する BIP-137 と BIP-322
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { ripemd160 } from "@noble/hashes/legacy.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex, concatBytes, utf8ToBytes } from "@noble/hashes/utils.js";
import { base64, bech32, createBase58check } from "@scure/base";

const sha256d = (b) => sha256(sha256(b));
const u32 = (n) => new Uint8Array([n, n >>> 8, n >>> 16, n >>> 24]);
const zero = (n) => new Uint8Array(n);
const push = (b) => concatBytes(new Uint8Array([b.length]), b); // lengths here are all below 0xfd
const txid = (tx) => bytesToHex(sha256d(tx).reverse());

// Test key published in the BIP-322 basic test vectors (bip-0322/basic-test-vectors.json).
// It is public, so never send funds to its address.
const wif = createBase58check(sha256).decode("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k");
const secretKey = wif.subarray(1, 33);
const pub = secp256k1.getPublicKey(secretKey, true);
const h160 = ripemd160(sha256(pub));
const address = bech32.encode("bc", [0, ...bech32.toWords(h160)]);
console.log("address:", address);

// BIP-137: double SHA-256 of a prefixed message, compact signature with a header byte
const message = "Hello World";
const m = utf8ToBytes(message);
const legacyHash = sha256d(concatBytes(push(utf8ToBytes("Bitcoin Signed Message:\n")), push(m)));
const rec = secp256k1.sign(legacyHash, secretKey, { prehash: false, format: "recovered" });
const encoded = base64.encode(concatBytes(new Uint8Array([39 + rec[0]]), rec.subarray(1))); // 39-42: P2WPKH
console.log("\nBIP-137 signature:", encoded);
// Verifier side: read the header, recover the public key, compare its hash with the address
const sig137 = base64.decode(encoded);
const recovered = secp256k1.recoverPublicKey(concatBytes(new Uint8Array([(sig137[0] - 27) & 3]), sig137.subarray(1)), legacyHash, { prehash: false });
console.log("header", sig137[0], "-> recovered key matches address:", bytesToHex(ripemd160(sha256(recovered))) === bytesToHex(h160));

// BIP-322: tagged message hash, virtual to_spend and to_sign transactions
const tag = sha256(utf8ToBytes("BIP0322-signed-message"));
const messageHash = sha256(concatBytes(tag, tag, m));
const challenge = concatBytes(new Uint8Array([0x00, 0x14]), h160); // P2WPKH scriptPubKey
const toSpend = concatBytes(u32(0), new Uint8Array([1]), zero(32), u32(0xffffffff),
  push(concatBytes(new Uint8Array([0x00, 0x20]), messageHash)), u32(0), new Uint8Array([1]), zero(8), push(challenge), u32(0));
const prevout = concatBytes(sha256d(toSpend), u32(0));
const opReturn = concatBytes(zero(8), push(new Uint8Array([0x6a])));
const toSign = concatBytes(u32(0), new Uint8Array([1]), prevout, push(zero(0)), u32(0), new Uint8Array([1]), opReturn, u32(0));
console.log("\nBIP-322 message hash:", bytesToHex(messageHash));
console.log("to_spend txid:", txid(toSpend));
console.log("to_sign txid: ", txid(toSign));

// Verify the "simple" signature from the test vectors: witness stack [signature, public key]
const vector = "smpAkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=";
const stack = base64.decode(vector.slice(3));
const der = stack.subarray(2, 2 + stack[1]);
const witnessKey = stack.subarray(3 + stack[1]);
// BIP-143 signature hash for input 0 of to_sign, SIGHASH_ALL
const scriptCode = push(concatBytes(new Uint8Array([0x76, 0xa9, 0x14]), h160, new Uint8Array([0x88, 0xac])));
const preimage = concatBytes(u32(0), sha256d(prevout), sha256d(u32(0)), prevout, scriptCode, zero(8), u32(0),
  sha256d(opReturn), u32(0), u32(1));
const ok = secp256k1.verify(der.subarray(0, -1), sha256d(preimage), witnessKey, { prehash: false, format: "der" });
console.log("vector signature:", vector.slice(0, 3), "prefix,", stack[0], "witness items,", ok ? "valid" : "invalid");
console.log("witness key is the address key:", bytesToHex(witnessKey) === bytesToHex(pub));
出力
address: bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l

BIP-137 signature: KOW2xi+ebJLeBtr674l4QH76dqDoVjLV80R9EFKFQX5rBrlCXPIZaYs8Yuayg0ZqjyiCbLy9pzZIS7JWT65/nsU=
header 40 -> recovered key matches address: true

BIP-322 message hash: f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a
to_spend txid: b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b
to_sign txid:  88737ae86f2077145f93cc4b153ae9a1cb8d56afa511988c149c5c8c9d93bddf
vector signature: smp prefix, 2 witness items, valid
witness key is the address key: true

メッセージハッシュと 2 つのトランザクション ID は basic-test-vectors.json の値と一致し、公開されている署名の検証にも成功します。BIP-137 署名は決定的(RFC 6979)なので、同じ鍵とメッセージからは常に同じ 88 文字が得られます。ヘッダー 40 は、リカバリ ID 1 の P2WPKH を意味します。Bitcoin メッセージ検証ツールでは、任意のアドレスについて両方の形式を検証できます。

実装者にとっての変化

  • 検証の負担が増える:BIP-322 には、トランザクションのシリアライズ、スクリプトの種類ごとの署名ハッシュ計算(レガシー、SegWit v0 には BIP-143、Taproot には BIP-341)、そして理想的には完全なスクリプトインタープリターが必要です。btcd(テストベクトルの出典)などのライブラリがこれらを提供しています。
  • 署名にはトランザクション用のコードを再利用できる:ウォレットやハードウェアデバイスは to_sign を他のトランザクションと同じように、多くの場合 PSBT として署名します。そのため、マルチシグのコーディネーターや Taproot ウォレットも、メッセージ専用のコードなしで動作します。
  • 形式の判別:バージョン 1.0.0 で 3 文字のプレフィックスが必須になりました。これは互換性のない変更です。検証者は、以前の実装との互換性のために、プレフィックスのない署名を smp として扱ってもかまいません。
  • 鍵の復元はない:検証者はアドレスを知っている必要がありますが、通常アドレスはいずれにせよ提示されます。
  • 他の署名と同じ限界:どちらの形式も、署名者がまだそのコインを管理していることや、署名者が他人に代わって署名しないことは証明しません。

どちらを選ぶべきか

  • Taproot、P2WSH、マルチシグ、スクリプトのアドレス:BIP-322 を使います。BIP-137 ではこれらを表現できません。
  • 出金先アドレスを検証する取引所とカストディアン(トラベルルールの確認):すべてのタイプで BIP-322 を受け付け、P2PKH と単一鍵の SegWit では BIP-137 も受け付けます。
  • 準備金の証明、または特定の UTXO の証明:BIP-322 の資金証明(pof)を使います。
  • レガシーの P2PKH アドレスと古いウォレット:BIP-137 は引き続き有効で、Bitcoin Core が生成するのもこの形式です。
  • Bitcoin ウォレットでのログイン:Sign-In with Ethereum と同じ理由から、ドメイン、nonce、有効期限を含むメッセージに BIP-322 のシンプル形式で署名します。

移行時の注意点

  1. 両方を検証する:署名にプレフィックスがある場合、またはアドレスが P2PKH でない場合は BIP-322 を試し、ヘッダーが 27〜42 の 65 バイトの署名では BIP-137 にフォールバックします。
  2. ヘッダーの扱いを統一する:ヘッダーが P2PKH の圧縮鍵の範囲にある SegWit 署名は、復元した鍵が SegWit アドレスと一致する場合に限って受け付け、その方針を文書化します。
  3. BIP-322 で署名するときは、バージョン 1.0.0 の要件どおり、プレフィックス付きの署名を出力します。
  4. 誤ったメッセージ、誤ったアドレス、不正なプレフィックスといったエラーケースも含めて、公開されているテストベクトルでテストします(basic-test-vectors.json、generated-test-vectors.json)。

参考資料

すべての比較