跳到主要内容
ecdsa.com

BIP-137 vs BIP-322:比特币地址的消息签名

BIP-137 用单密钥地址背后的密钥签名,验证者可以从签名中恢复出这个密钥。BIP-322 则通过签署一笔永远无法广播的交易,证明你能够花费任意地址中的资金。

在 BIP-137 和 BIP-322 之间做选择,就是在比特币通过签名消息证明地址控制权的新旧两种方式之间做选择。BIP-137 记录了由来已久的“Bitcoin Signed Message”格式:65 字节的可恢复 ECDSA 签名,开头的头部字节指明应从恢复出的公钥推导出哪种类型的地址。BIP-322 把签名推广到任意脚本:消息以承诺的形式写入一笔虚拟交易,签名就是花费该地址所需的任何见证数据,由常规的脚本解释器检查。

概览

BIP-137BIP-322
状态已部署(2019年分配编号;格式本身要早得多)自 1.0.0 版(2026年4月15日)起为“已完成”;2.0.0 版发布于2026年6月4日
消息哈希对 "\x18Bitcoin Signed Message:\n"、长度和消息计算双重 SHA-256BIP-340 标签哈希(SHA-256),标签为 BIP0322-signed-message
签名的对象直接对消息哈希签名一笔虚拟的 to_sign 交易,它花费一个虚拟的 to_spend 输出
地址类型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 存入头部字节:27~30 表示使用未压缩公钥的 P2PKH,31~34 表示压缩公钥,35~38 表示 P2SH-P2WPKH,39~42 表示原生 SegWit P2WPKH。验证者通过公钥恢复从签名计算出公钥,并检查它的哈希是否与地址一致。

这种做法只适用于地址是单个公钥哈希的情况。Taproot 地址承诺的是经过调整(tweak)的公钥,并使用 Schnorr 签名,而 Schnorr 签名不支持公钥恢复;多签和其他脚本则根本没有单一的公钥。SegWit 的头部范围是后来才加上的,并非每个钱包都采用了它们,因此一个钱包生成的签名在另一个钱包中可能验证失败;而 Bitcoin Core 自己的 signmessage 和 verifymessage 至今仍只接受传统 P2PKH 地址。

BIP-322 的工作原理

BIP-322 用标签哈希对消息计算哈希,并把结果放进虚拟交易 to_spend 的输入脚本中;这笔交易只有一个输出,其 scriptPubKey 就是该地址的 scriptPubKey。第二笔交易 to_sign 把这个输出花费到一个 OP_RETURN。签名者生成其钱包花费该地址时本来就会生成的见证数据;验证者重建这两笔交易并运行脚本解释器。由于 to_spend 花费的是一个不存在的输入,这两笔交易都永远不可能在网络上生效。

简单格式(smp)只携带见证栈。完整格式(ful)携带整笔 to_sign 交易,带时间锁的脚本或非见证输入需要用到它;此时验证者会报告该证明在什么时间、什么币龄下有效。资金证明(pof)是一个已最终确定的 PSBT,它同时会花费真实的 UTXO,因此签名能够表明对特定币的控制权。传统的 BIP-137 格式仍然允许使用,但只限于 P2PKH 地址。

用 BIP-322 测试向量检验两种格式

下面的脚本使用 BIP-322 基础测试向量中的私钥(WIF 格式)和地址。这个私钥是为测试而公开的,任何人都能花费其地址中的资金,因此这个地址绝不能存放资金。脚本为“Hello World”生成并验证一个 BIP-137 签名,重建 BIP-322 消息哈希和两笔虚拟交易的 ID,并用 BIP-143 签名哈希验证测试向量中公布的 smp 签名(Node.js 26、@noble/curves 2.3.0、@noble/hashes 2.3.0、@scure/base 2.4.0)。

同一个 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

消息哈希和两个交易 ID 都与 basic-test-vectors.json 中的值一致,公布的签名也验证通过。BIP-137 签名是确定性的(RFC 6979),因此相同的密钥和消息总是得到同样的 88 个字符;头部 40 表示恢复 ID 为 1 的 P2WPKH。比特币消息验证工具可以针对任意地址检查这两种格式。

对实现者来说有哪些变化

  • 验证更繁重:BIP-322 需要交易序列化、针对每种脚本类型的签名哈希(传统类型;SegWit v0 用 BIP-143;Taproot 用 BIP-341),最好还要有完整的脚本解释器。btcd(测试向量的来源)等库提供了这些功能。
  • 签名复用交易代码:钱包和硬件设备像签署其他任何交易一样签署 to_sign,通常以 PSBT 的形式进行,因此多签协调方和 Taproot 钱包无需专门的消息签名代码就能工作。
  • 格式识别:1.0.0 版把三个字母的前缀改为必需,这是一项不兼容的变更;为了与早期实现兼容,验证者可以把不带前缀的签名当作 smp 处理。
  • 没有公钥恢复:验证者必须知道地址,而地址通常本来就会提供。
  • 与任何签名相同的局限:两种格式都无法证明签名者仍然控制着这些币,也无法证明签名者不会代替他人签名。

如何选择

  • Taproot、P2WSH、多签或脚本地址:BIP-322;BIP-137 无法表达这些地址。
  • 交易所和托管机构验证提现地址(旅行规则检查):对所有类型接受 BIP-322,对 P2PKH 和单密钥 SegWit 地址接受 BIP-137。
  • 储备证明或特定 UTXO 的证明:BIP-322 资金证明(pof)。
  • 传统 P2PKH 地址和旧钱包:BIP-137 依然有效,而且 Bitcoin Core 生成的就是这种格式。
  • 用比特币钱包登录:对包含域名、nonce 和过期时间的消息使用 BIP-322 简单格式签名,理由与 Sign-In with Ethereum 相同。

迁移说明

  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),包括消息错误、地址错误和前缀错误等错误用例。

参考资料

全部对比