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

Bitcoin メッセージ署名:BIP-137 と BIP-322 を解説

「このメッセージにアドレスで署名してください」は 1 つの操作のように聞こえます。しかし実際には、互換性のない 2 つの形式、3 種類のヘッダー方式、そして今年エンコーディングが変わった標準があります。それぞれの形式が何に署名するのか、どのウォレットが何を生成するのかを説明し、Bitcoin Core と BIP 自身のテストベクトルで確認した検証コードを紹介します。

要点:形式は 2 つあります。従来形式は、後から BIP-137 として仕様化されたもので、"\x18Bitcoin Signed Message:\n" ‖ length ‖ message に対する 65 バイトの復元可能な ECDSA 署名です。この形式が証明するのは鍵を管理していることだけで、先頭バイト(27〜42)は署名者が意図したアドレスタイプを示します。Bitcoin Core がこの形式で署名するのはレガシー P2PKH アドレスだけで、SegWit アドレスに使うヘッダーはウォレットによって異なります。BIP-322(ステータスは Complete)はアドレスを管理していることを証明します。そのアドレスから資金を使用する仮想トランザクションを構築し、署名はその使用のウィットネスになるため、P2WPKH、P2TR、マルチシグ、スクリプトにも使えます。BIP のバージョン 2.0.0 以降、署名には smp(シンプル)、ful(フル)、pof(資金証明)のいずれかのプレフィックスが付きます。プレフィックスを理解するライブラリで検証し、古いウォレットが生成するプレフィックスなしのシンプル署名も受け入れてください。

従来形式(BIP-137)

ダイジェストは、固定のプレフィックス、Bitcoin の varint で表したメッセージ長、メッセージのバイト列を連結したものに対する二重 SHA-256 です。署名は header ‖ r ‖ s の 65 バイトで、通常は base64 でエンコードされます。ヘッダーには、検証者が署名から公開鍵を計算するためのリカバリ ID(0〜3)に、鍵とアドレスのタイプを表す定数を加えた値が入ります(BIP-137、ステータスは Deployed)。

ヘッダー意味
27〜30P2PKH、非圧縮公開鍵
31〜34P2PKH、圧縮公開鍵
35〜38P2SH-P2WPKH(ネステッド SegWit)
39〜42P2WPKH(ネイティブ SegWit、bc1q…)

検証では公開鍵を復元し、ヘッダーが示すタイプのアドレスを導出して比較します。トランザクションもスクリプトもないため、この形式では「このマルチシグアドレス」や「この Taproot アドレス」を表現できず、表現できるのは「この鍵の保有者」だけです。それを超える用途については、BIP-137 自身が BIP-322 を参照するよう示しています。

以下のモジュールは、@noble/curves と @scure/base を使って従来形式を実装し、後半では P2WPKH 向けの BIP-322 シンプル署名を実装しています。

btcmsg.mjs(@noble/curves 2.4.0、@noble/hashes 2.4.0、@scure/base 2.4.0 を使用)
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { ripemd160 } from "@noble/hashes/legacy.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { bech32, bech32m, createBase58check } from "@scure/base";

const b58 = createBase58check(sha256);
const sha256d = (b) => sha256(sha256(Uint8Array.from(b)));
const hash160 = (b) => ripemd160(sha256(b));
const utf8 = (s) => new TextEncoder().encode(s);
const cat = (...parts) => Uint8Array.from(parts.flatMap((p) => [...p]));
const varint = (n) => (n < 0xfd ? [n] : [0xfd, n & 0xff, n >> 8]); // messages under 64 KiB
export const NETWORKS = {
  mainnet: { p2pkh: 0x00, p2sh: 0x05, hrp: "bc" },
  regtest: { p2pkh: 0x6f, p2sh: 0xc4, hrp: "bcrt" },
};

/** Addresses of one compressed public key. */
export function addressesOf(pubkey, net = NETWORKS.mainnet) {
  const h = hash160(pubkey);
  const redeem = cat([0x00, 0x14], h); // P2SH-P2WPKH redeem script
  return {
    p2pkh: b58.encode(cat([net.p2pkh], h)),
    "p2sh-p2wpkh": b58.encode(cat([net.p2sh], hash160(redeem))),
    p2wpkh: bech32.encode(net.hrp, [0, ...bech32.toWords(h)]),
  };
}

/** scriptPubKey of an address, for BIP-322's message_challenge. */
export function scriptPubKey(address, net = NETWORKS.mainnet) {
  if (address.toLowerCase().startsWith(`${net.hrp}1`)) {
    const isV0 = address.toLowerCase().startsWith(`${net.hrp}1q`);
    const { words } = (isV0 ? bech32 : bech32m).decode(address);
    const program = (isV0 ? bech32 : bech32m).fromWords(words.slice(1));
    return cat([words[0] === 0 ? 0x00 : 0x50 + words[0], program.length], program);
  }
  const raw = b58.decode(address);
  if (raw[0] === net.p2pkh) return cat([0x76, 0xa9, 0x14], raw.slice(1), [0x88, 0xac]);
  if (raw[0] === net.p2sh) return cat([0xa9, 0x14], raw.slice(1), [0x87]);
  throw new Error(`unsupported address ${address}`);
}

// ---------------------------------------------------------------------------- BIP-137 (legacy)
/** The digest every legacy message signature covers. */
export const legacyDigest = (message) => {
  const m = utf8(message);
  return sha256d(cat([0x18], utf8("Bitcoin Signed Message:\n"), varint(m.length), m));
};
const HEADER_BASE = { p2pkh: 31, "p2sh-p2wpkh": 35, p2wpkh: 39 }; // compressed keys

/** 65-byte compact signature, base64. headerFor "electrum" keeps 31-34 for every address type. */
export function signLegacy(message, privateKey, type = "p2pkh", headerFor = "bip137") {
  const sig = secp256k1.Signature.fromBytes(secp256k1.sign(legacyDigest(message), privateKey, { prehash: false, format: "recovered" }), "recovered");
  const base = headerFor === "electrum" ? 31 : HEADER_BASE[type];
  return Buffer.from(cat([base + sig.recovery], sig.toBytes("compact"))).toString("base64");
}

/** Recovers the key and checks it against the address; strict mode also checks the header type. */
export function verifyLegacy(address, message, signatureBase64, { net = NETWORKS.mainnet, strict = false } = {}) {
  const raw = Buffer.from(signatureBase64, "base64");
  if (raw.length !== 65) throw new Error(`expected 65 bytes, got ${raw.length}`);
  const header = raw[0];
  if (header < 27 || header > 42) throw new Error(`header byte ${header} out of range`);
  const recovery = (header - 27) % 4;
  const typeFromHeader = header >= 39 ? "p2wpkh" : header >= 35 ? "p2sh-p2wpkh" : "p2pkh";
  const compressed = header >= 31;
  const sig = secp256k1.Signature.fromBytes(raw.subarray(1), "compact").addRecoveryBit(recovery);
  const pubkey = sig.recoverPublicKey(legacyDigest(message)).toBytes(compressed);
  const candidates = addressesOf(pubkey, net);
  const match = Object.entries(candidates).find(([, a]) => a === address)?.[0];
  if (!match) return { valid: false };
  if (strict && match !== typeFromHeader) return { valid: false, reason: `header says ${typeFromHeader}, address is ${match}` };
  return { valid: true, type: match, header };
}

// ---------------------------------------------------------------------------- BIP-322 (simple, P2WPKH)
const tag = sha256(utf8("BIP0322-signed-message"));
/** sha256(tag || tag || m), a BIP-340 tagged hash of the raw message. */
export const messageHash = (message) => sha256(cat(tag, tag, utf8(message)));
const le32 = (n) => [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, n >>> 24];
const zero64 = new Array(8).fill(0);

/** The two virtual transactions of BIP-322, serialized without witness. Returns txids too. */
export function virtualTransactions(address, message, net = NETWORKS.mainnet) {
  const challenge = scriptPubKey(address, net);
  const toSpend = cat(
    le32(0), [1], new Array(32).fill(0), le32(0xffffffff), // version 0, one input spending 000…000:FFFFFFFF
    [34, 0x00, 0x20], messageHash(message), le32(0), // scriptSig OP_0 PUSH32[message_hash], nSequence 0
    [1], zero64, [challenge.length], challenge, le32(0), // one output, value 0, scriptPubKey = challenge
  );
  const toSpendId = sha256d(toSpend); // internal byte order
  const toSign = cat(le32(0), [1], toSpendId, le32(0), [0], le32(0), [1], zero64, [1, 0x6a], le32(0));
  const display = (h) => Buffer.from(h).reverse().toString("hex");
  return { challenge, toSpendId, toSpendTxid: display(toSpendId), toSignTxid: display(sha256d(toSign)) };
}

/** Verifies a BIP-322 simple signature for a P2WPKH address. Accepts the "smp" prefix or none. */
export function verifySimpleP2wpkh(address, message, signature, net = NETWORKS.mainnet) {
  const b64 = signature.startsWith("smp") ? signature.slice(3) : signature;
  const w = Buffer.from(b64, "base64");
  // Witness stack: count, then <len><DER sig + sighash byte>, <len><33-byte pubkey>.
  if (w[0] !== 2) throw new Error("expected a two-item witness stack");
  const sigWithType = w.subarray(2, 2 + w[1]);
  const pubkey = w.subarray(3 + w[1], 3 + w[1] + w[2 + w[1]]);
  const { challenge, toSpendId } = virtualTransactions(address, message, net);
  if (challenge[0] !== 0x00 || challenge[1] !== 20) throw new Error("not a P2WPKH address");
  if (Buffer.compare(Buffer.from(hash160(pubkey)), Buffer.from(challenge.subarray(2))) !== 0) return false;
  if (sigWithType.at(-1) !== 0x01) throw new Error("only SIGHASH_ALL is allowed");
  // BIP-143 digest of to_sign input 0, spending to_spend:0 worth 0 sat.
  const scriptCode = cat([0x19, 0x76, 0xa9, 0x14], challenge.subarray(2), [0x88, 0xac]);
  const outputs = cat(zero64, [1, 0x6a]);
  const preimage = cat(
    le32(0), sha256d(cat(toSpendId, le32(0))), sha256d(le32(0)), toSpendId, le32(0),
    scriptCode, zero64, le32(0), sha256d(outputs), le32(0), le32(1),
  );
  const der = sigWithType.subarray(0, -1);
  return secp256k1.verify(der, sha256d(preimage), pubkey, { prehash: false, format: "der", lowS: true });
}

Bitcoin Core の対応範囲

Bitcoin Core の signmessage RPC と verifymessage RPC は従来形式を実装していますが、対象はレガシーアドレスだけです。regtest ノードで、タイプごとにアドレスを 1 つずつ作成して試しました。

run.sh(bitcoin/bitcoin:31.1 で実行)
#!/bin/sh
# Bitcoin Core in regtest: which address types can sign and verify messages, and the exact errors.
set -e
DATA=/tmp/regtest
mkdir -p "$DATA"
bitcoind -regtest -datadir="$DATA" -daemonwait -fallbackfee=0.0001 >/dev/null
cli() { bitcoin-cli -regtest -datadir="$DATA" "$@"; }
cli -named createwallet wallet_name=lab >/dev/null
echo "$(bitcoind -version | head -1)"

MSG="ecdsa.com lab, 2026-10-20"
for type in legacy p2sh-segwit bech32 bech32m; do
  addr=$(cli getnewaddress "" "$type")
  printf '%-12s %s\n' "$type" "$addr"
  if sig=$(cli signmessage "$addr" "$MSG" 2>/tmp/err); then
    echo "             signmessage: $sig"
    echo "             verifymessage: $(cli verifymessage "$addr" "$sig" "$MSG")"
    echo "$addr $sig" > "/w/core-$type.txt"
  else
    echo "             signmessage: $(tr '\n' ' ' < /tmp/err)"
  fi
done

# A signature made elsewhere for a segwit address: Core cannot even attempt it.
seg=$(cli getnewaddress "" bech32)
leg=$(cut -d' ' -f2 /w/core-legacy.txt)
echo "verifymessage with a bech32 address: $(cli verifymessage "$seg" "$leg" "$MSG" 2>&1 | tr '\n' ' ')"
cli stop >/dev/null
出力
Bitcoin Core daemon version v31.1.0 bitcoind
legacy       mnaZRqP42LwTsVNQGfSb1eeTMvVzkaMoWp
             signmessage: ICZ/Fs0I/i7XNJbC+ubYkwpDZXI0uFZU1oaqrSaJlS+hYavTSzdfPKmRkLJrUdi6/RXl9WsYNEW5OU9l9hfMHCI=
             verifymessage: true
p2sh-segwit  2MyhmVFuFbTr4kAF1S4TtVmjEgkdouEntKK
             signmessage: error code: -3 error message: Address does not refer to key 
bech32       bcrt1qulxz0zapcld55a5twc9k88028y6dyxrggxug0n
             signmessage: error code: -3 error message: Address does not refer to key 
bech32m      bcrt1pq8x39vj0qay20825g8m3kffzx7sqwu9lfzmtjejta94luunwkmfsdc0hnd
             signmessage: error code: -3 error message: Address does not refer to key 
verifymessage with a bech32 address: error code: -3 error message: Address does not refer to key 

SegWit と Taproot のアドレスはすべて、署名でも、他者が作成した署名の検証でも、Address does not refer to key というエラーになります。従来形式の署名の先頭バイトは今回の実行では 31 または 32(base64 で H または I)で、圧縮形式の P2PKH 鍵を示しています。

この記事の実装は、Bitcoin Core と同じバイト列を生成します。実行用に生成した鍵を pkh() ディスクリプターとして regtest ウォレットにインポートし、両者で同じメッセージに署名しました。

出力:legacy-prepare.mjs を実行し、続いて bitcoin/bitcoin:31.1 で crosscheck.sh を実行
address mk1giEaBaAFPXNRMQcZNptPAp9kJmttKyp
core signature: H0bepOQGQpAS+FqLbBoVqjiXqArVq4AQlD59fhwpAX8bdTwCAlE407C2Ik9hTp5pxcsCv6C3arMw+bGjzYtH4mE=
our signature:  H0bepOQGQpAS+FqLbBoVqjiXqArVq4AQlD59fhwpAX8bdTwCAlE407C2Ik9hTp5pxcsCv6C3arMw+bGjzYtH4mE=
identical: yes
core verifymessage(our signature): true

署名が一致するのは、どちらも決定的 nonce(RFC 6979)と low-S 正規化を使っているからです。ランダムな nonce を使う署名者は、毎回異なる、しかし同じく有効な署名を生成します。

SegWit アドレスのヘッダー問題

SegWit アドレスに対して従来形式でメッセージに署名するウォレットの間では、ヘッダーの扱いが一致していません。Electrum はすべてのアドレスタイプでヘッダー 27〜34 を使って署名し、検証時には、復元した鍵が取り得るすべての単一鍵のアドレスタイプを試します(electrum/bitcoin.py)。Trezor は BIP-137 に従い、P2SH-P2WPKH では 4、P2WPKH では 8 を加えます。代わりに Electrum 方式で生成するオプション(no_script_type)もあります(trezor-firmware、sign_message.py)。そのため検証者は、厳格なチェック(ヘッダーがアドレスのタイプを示していなければならない)と緩いチェック(復元した鍵のどのアドレスでもよい)のどちらかを選ばなければなりません。同じ鍵と同じメッセージで 2 つの方式の署名を作り、この記事の検証コードと bip322-js 4.0.0 で検証した結果は次のとおりです。

headers.mjs
// Same key, same message, P2WPKH address: the BIP-137 header and the Electrum convention, and who accepts which.
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { Verifier } from "bip322-js";
import { addressesOf, signLegacy, verifyLegacy } from "./btcmsg.mjs";

const priv = secp256k1.utils.randomSecretKey();
const { p2pkh, p2wpkh } = addressesOf(secp256k1.getPublicKey(priv, true));
const message = "ecdsa.com lab, 2026-10-20";
const lib = (address, sig, strict) => {
  try {
    return Verifier.verifySignature(address, message, sig, strict);
  } catch (e) {
    return `throws: ${e.message}`;
  }
};
for (const [label, sig] of [
  ["BIP-137 header (39-42)", signLegacy(message, priv, "p2wpkh", "bip137")],
  ["Electrum header (31-34)", signLegacy(message, priv, "p2wpkh", "electrum")],
]) {
  const header = Buffer.from(sig, "base64")[0];
  const loose = verifyLegacy(p2wpkh, message, sig);
  const strict = verifyLegacy(p2wpkh, message, sig, { strict: true });
  console.log(`${label}: header ${header}`);
  console.log(`  ours, loose:  ${loose.valid}   ours, strict: ${strict.valid}${strict.reason ? ` (${strict.reason})` : ""}`);
  console.log(`  bip322-js, loose: ${lib(p2wpkh, sig, false)}   bip322-js, strict: ${lib(p2wpkh, sig, true)}`);
  console.log(`  same signature for the P2PKH address of the key: ours, loose ${verifyLegacy(p2pkh, message, sig).valid}`);
}
出力(node:24.22.0-bookworm-slim)
BIP-137 header (39-42): header 39
  ours, loose:  true   ours, strict: true
  bip322-js, loose: true   bip322-js, strict: true
  same signature for the P2PKH address of the key: ours, loose true
Electrum header (31-34): header 31
  ours, loose:  true   ours, strict: false (header says p2pkh, address is p2wpkh)
  bip322-js, loose: true   bip322-js, strict: false
  same signature for the P2PKH address of the key: ours, loose true

緩い検証は両方を受け入れ、さらに同じ署名を、その鍵の P2PKH アドレスの証明としても受け入れます。従来形式の署名が本来意味しているのはこれです。つまり「この鍵の保有者」であり、それはその鍵から導出されるすべてのアドレスに当てはまります。厳格な検証は、Electrum 方式の SegWit 署名をすべて拒否します。SegWit アドレスに対する従来形式の署名を受け入れなければならない場合は、緩い検証を行い、検証したのは鍵の管理であることを記録してください。

BIP-322:アドレスの管理を証明する

BIP-322(bip-0322.mediawiki、ステータスは Complete、バージョン 2.0.0)は、メッセージ形式を新たに考案する代わりに、トランザクションの仕組みを再利用します。構築する仮想トランザクションは次の 2 つです。

  • to_spend:バージョン 0 です。入力は 1 つで、存在しないアウトポイント 000…000:FFFFFFFF を使用し、scriptSig は OP_0 PUSH32[message_hash] です。出力は 1 つで、金額は 0 サトシ、scriptPubKey は証明対象のアドレスです。message_hash は BIP-340 のタグ付きハッシュで、タグ BIP0322-signed-message を使った SHA256(SHA256(tag) ‖ SHA256(tag) ‖ message) です。
  • to_sign:バージョン 0 です。入力は 1 つで to_spend:0 を使用し、出力は OP_RETURN への 0 サトシの出力 1 つです。

署名とは、ウォレットがそのアドレスの任意の出力を使用するのと同じように、to_sign で to_spend:0 を使用することです。検証では、その使用を標準のスクリプトルール(SIGHASH_ALL または Taproot のデフォルト、low-S と厳密な DER、CLEANSTACK など)に従ってチェックします。どちらのトランザクションもネットワーク上では有効ではないため、署名をブロードキャストして資金を移動させることはできません。

エンコーディングは次のとおりです。

種類プレフィックス内容用途
シンプルsmpウィットネススタック(base64)ネイティブ SegWit と Taproot、タイムロックなし
フルful署名済みの to_sign 全体(base64)ネステッド SegWit、レガシー、タイムロックを含むあらゆるスクリプト
フル(資金証明)pof追加の入力を含む、ファイナライズ済みの to_sign の PSBT特定の UTXO の管理の証明
従来形式なしBIP-137 署名レガシー P2PKH アドレスでのみ使用可

種類を示すプレフィックスはバージョン 2.0.0 で新たに導入されたもので、現在は署名者がこれを含めなければなりません(MUST)。互換性のために「プレフィックスがない場合、検証者はシンプル形式と見なす場合がある」とされており、これにより、以前の版の仕様に基づいて作られたウォレットの署名も引き続き通用します。検証の結果は 2 通りではなく 3 通りあります。有効、無効、そして関係するスクリプトを検証者が評価できない場合の判定不能(inconclusive)です。

マルチシグとハードウェアウォレット:PSBT による署名

マルチシグアドレスでは、ウィットネスに複数の鍵による署名が必要で、それらの鍵は別々の人やデバイスが保有しています。BIP-322 はこれを、マルチシグの利用者がすでに使っているツールで処理します。to_sign トランザクションを PSBT として受け渡し、新しいグローバルフィールド PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE(タイプ 0x09)にメッセージを格納します。このフィールドがあり、最初の入力が対応する to_spend の出力 0 を使用し、出力が金額ゼロの OP_RETURN 1 つだけであることを確認した署名者は、トランザクションではなくメッセージとアドレスをユーザーに表示しなければならず、そのうえで通常の使用と同じように署名します。各共同署名者が部分署名を追加し、ファイナライズした結果が ful 署名になります。このフィールドを認識しないデバイスには、0 サトシを使用するトランザクションのように見えます。この手順へのウォレットの対応が暗号技術そのものより重要なのは、そのためです。

BIP には 2 つのリファレンス実装が挙げられています。1 つは基本的なサポートを追加する Bitcoin Core のプルリクエスト(#24058)で、マージされずにクローズされました。もう 1 つは完全なサポートを追加する btcd のプルリクエスト(#2521)で、生成されたテストベクトルの出典であり、2026年10月20日の時点でまだオープンのままです。どちらもリリースには含まれていません。下の表のウォレットとライブラリのうち BIP-322 に対応しているものが、それぞれ独自に実装しているのは、これが理由の一つです。

BIP のテストベクトルによる確認

BIP-322 には基本テストベクトルが付属しています。3 つのメッセージに対するメッセージハッシュとトランザクション ID、そして P2WPKH アドレスのシンプル署名です。これらの検証に必要なのは、アドレス、メッセージ、署名だけです。同じ署名を、新しいプレフィックスがある場合とない場合の両方で、bip322-js 4.0.0 にも渡しました。

vectors.mjs
// BIP-322's published test vectors (addresses, messages and signatures only), against btcmsg.mjs and bip322-js 4.0.0.
import { readFileSync } from "node:fs";
import { Verifier } from "bip322-js";
import { messageHash, verifySimpleP2wpkh, virtualTransactions } from "./btcmsg.mjs";

const vectors = JSON.parse(readFileSync("bip322-basic-test-vectors.json", "utf8"));
for (const v of vectors.tx_hashes) {
  const { toSpendTxid, toSignTxid } = virtualTransactions(v.address, v.message);
  const ok = Buffer.from(messageHash(v.message)).toString("hex") === v.message_hash && toSpendTxid === v.to_spend_tx_hash && toSignTxid === v.to_sign_tx_hash;
  console.log(`hashes for ${JSON.stringify(v.message).padEnd(16).slice(0, 16)} message_hash, to_spend, to_sign match: ${ok}`);
}
for (const v of vectors.simple.filter((x) => x.type === "p2wpkh")) {
  for (const sig of v.bip322_signatures) {
    const ours = verifySimpleP2wpkh(v.address, v.message, sig);
    const call = (s) => { try { return Verifier.verifySignature(v.address, v.message, s); } catch (e) { return `throws: ${e.message}`; } };
    console.log(`${JSON.stringify(v.message).padEnd(14)} ${sig.slice(0, 12)}...  ours: ${ours} | bip322-js: ${call(sig)} | bip322-js, prefix removed: ${call(sig.slice(3))}`);
  }
}
const wrong = vectors.error.find((e) => e.description.startsWith("wrong message for valid simple p2wpkh"));
console.log(`error vector "${wrong.description}": ours ${verifySimpleP2wpkh(wrong.address, wrong.message, wrong.signature)}`);
出力(node:24.22.0-bookworm-slim)
hashes for ""               message_hash, to_spend, to_sign match: true
hashes for "Hello World"    message_hash, to_spend, to_sign match: true
hashes for "UTF-8 support:  message_hash, to_spend, to_sign match: true
""             smpAkcwRAIgM...  ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
""             smpAkgwRQIhA...  ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
"Hello World"  smpAkcwRAIgZ...  ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
"Hello World"  smpAkgwRQIhA...  ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
error vector "wrong message for valid simple p2wpkh signature (empty message was signed)": ours false

上の実装は、すべてのハッシュを再現し、プレフィックスの有無にかかわらずすべての P2WPKH シンプル署名を検証し、エラーベクトルを拒否します。広く使われている JavaScript ライブラリの bip322-js 4.0.0 は、smp プレフィックスを理解しません。プレフィックスを base64 としてデコードしてしまい buffer too small というエラーになりますが、プレフィックスを除いた同じ署名は検証に成功します。そのため、このライブラリが更新されるまでは、これを使って構築されたサービスで、新しいウォレットが生成した署名の検証が失敗します。シンプル署名については、呼び出す前に既知のプレフィックスを取り除くことが、当面の現実的な回避策になります。

各ソフトウェアが生成・受け入れる形式

各プロジェクトのソースコード(注記のあるものはテスト実行)に基づく、2026年10月時点の状況です。

ソフトウェア従来形式(BIP-137)BIP-322備考
Bitcoin Core 31.1P2PKH のみ非対応上記でテスト済み
Electrum(master)P2PKH、P2WPKH、P2SH-P2WPKH、ヘッダー 27〜34非対応両方のヘッダー方式を検証。単一鍵でないアドレスは拒否
Trezor ファームウェア(main)P2PKH、P2SH-P2WPKH、P2WPKH、BIP-137 ヘッダー非対応Taproot:「Unsupported script type」
Sparrow(drongo master)対応シンプル、smp/ful/pof プレフィックス付き検証:シンプル形式、単一鍵の P2WPKH と P2TR
bip322-js 4.0.0対応(緩い検証または厳格な検証)シンプル、P2WPKH、P2SH-P2WPKH、単一鍵の P2TRプレフィックス非対応。上記の注を参照

実務上の帰結は次のとおりです。Taproot アドレスは BIP-322 でしか証明できません。マルチシグアドレスは従来形式ではまったく証明できません。そして、これらすべてのウォレットのユーザーからの証明を受け入れたいサービスは、従来形式の署名(SegWit では緩い検証)、プレフィックスなしの BIP-322 シンプル署名、プレフィックス付きの署名のすべてを受け入れる必要があります。

サービスでの検証方法

  1. メッセージはサービス側で決める:ドメイン、nonce またはタイムスタンプ、目的("Proof of ownership for example.com, nonce 7f3a…")を含め、Sign-In with Ethereum とまったく同じように、各 nonce は 1 回だけ受け入れてください。汎用的なテキストに対する署名は、同じテキストを要求する他のどのサービスでもリプレイされる可能性があります。
  2. バイト列を厳密に比較する:どちらの形式も生のメッセージに署名します。Electrum のインターフェースは、署名前に先頭と末尾の空白を取り除きます。コピーして貼り付けた際に末尾に入った改行や CRLF の改行コードがあると、ハッシュが変わり「署名が無効」という結果になります。
  3. 形式を判別する:次の順で判定します。まず smp、ful、pof のプレフィックスを確認し、次に、デコードするとちょうど 65 バイトになり先頭バイトが 27〜42 の base64 文字列かどうか(従来形式)を確認し、どちらでもなければプレフィックスなしの BIP-322 シンプル署名として試します。
  4. SegWit アドレスに対して従来形式の署名が何を証明するかを決める:緩い検証で証明されるのは鍵の管理であり、単一鍵のアドレスであれば、それはアドレスの管理と同じです。
  5. 判定不能は未検証として扱う:スクリプト(タイムロック、一般的でないスクリプト)を評価できない検証者は、成功と報告してはいけません。
  6. 資金証明ではチェーンを確認する:pof 署名が証明するのは、署名時点で列挙された出力を管理していたことです。それらがまだ未使用かどうかは別途照会する必要があり、それらがアドレスのすべての出力であることは決して証明されません。

出典