GitHub
深入技术
签名

签名

比特币中的签名数据是通过私钥对交易数据进行 ECDSASchnorr 签名生成的, 签名数据被放置在交易的 scriptSigwitness 字段中。目的是为了在脚本执行时, 通过 OP_CHECKSIG 等操作码的检查, 以确认是否可以花费该 UTXO

交易的签名针对的是特定的交易输入, 多个交易输入需要多个签名。

未签名交易

签名前需要确认交易的输入和输出:

  • 交易输入

    • txid - 要花费的 UTXO 来自于哪个交易ID
    • vout - 要花费的 UTXO 来自于交易输出的哪个索引号
  • 交易输出

    • amount - 输出的聪的数量
    • scriptPubKey - 锁定脚本。根据接收地址生成的锁定脚本。

未签名交易构造器

签名

比特币中签名指的是计算出交易中 ScriptSigWitness 字段的签名数据, 用于验证交易的合法性。

不同的交易输入的锁定脚本类型具有不同的签名过程, 但总体上都需要经历下面的步骤:

  • 确认签名哈希类型
  • 构造未签名数据
  • 数据签名
  • 签名数据编码
  • 解锁脚本

签名交易

1. 确认签名哈希类型

签名哈希类型 SigHash, 用于指定签名的数据

  • SIGHASH_DEFAULT - 值为 0x00, 用于 P2TR 默认值, 等同于 SIGHASH_ALL, 签名所有输入和输出。
  • SIGHASH_ALL - 值为 0x01, 默认值, 签名所有输入和输出, 意味着交易的输入和输出有任何变动, 签名将失效。
  • SIGHASH_NONE - 值为 0x02, 签名所有输入, 但不签名任何输出, 除当前要签名的输入外, 其他交易输入的 sequence 设置为0, 意味着生成的签名可以用于相同输入但有不同输出的交易。
  • SIGHASH_SINGLE - 值为 0x03, 签名所有输入, 但只签名对应索引的输出。交易输出的数量最多等于要签名的输入的索引号加 1, 例如签名的输入索引是 3, 则输出数量最多为 4。同时在输出中, 除了对应索引的输出外, 其他输出的金额设置为最大值, 锁定脚本为空。最后除当前要签名的输入外, 其他交易输入的 sequence 设置为0
  • SIGHASH_ANYONECANPAY - 值为 0x80, 与其他类型组合使用:
    • SIGHASH_ALL | SIGHASH_ANYONECANPAY - 值为 0x81, 签名当前输入和所有输出
    • SIGHASH_NONE | SIGHASH_ANYONECANPAY - 值为 0x82, 签名当前输入, 但不签名输出
    • SIGHASH_SINGLE | SIGHASH_ANYONECANPAY - 值为 0x83, 签名当前输入, 但只签名对应索引的输出

2. 构造未签名数据

对不同的类型的 UTXO 锁, 有不同的构造过程。

非隔离见证锁

非隔离见证锁有 P2PKHP2SHP2MS

当要签名的交易输入的 UTXO 锁是非隔离见证锁时, 则需要将 scriptSig 字段临时替换为 UTXOscriptPubKey 字段, 其他交易输入的 scriptSig 字段保持为空。

例如有如下交易

02000000020323f0c5cdd3408336cd7e6b6df9cf0ccde996f363b64a066497a5a60c44f7e40000000000ffffffffcd048bf2054b6885f29246ed1ae55c0e329ed3f0ccaa2d597c6b99b0ed3b97160000000000ffffffff016400000000000000225120b2049a6d884575fe95e3fcaeaedae4ec4feaecccc30fad156f12923753c0954e00000000

要签名的第 0 个交易输入的 UTXO 锁是 P2PKH 类型,需将第 0 个交易输入scriptSig 字段替换为 scriptPubKey 字段, 当签名哈希类型是 SIGHASH_ALL 时, 得到签名数据是:

02000000020323f0c5cdd3408336cd7e6b6df9cf0ccde996f363b64a066497a5a60c44f7e4000000001976a914c189d7f7ea4333daec66a645cb3388163c22900b88acffffffffcd048bf2054b6885f29246ed1ae55c0e329ed3f0ccaa2d597c6b99b0ed3b97160000000000ffffffff016400000000000000225120b2049a6d884575fe95e3fcaeaedae4ec4feaecccc30fad156f12923753c0954e00000000

sigHash 不同时, 需要对交易数据进行不同的处理:

/**
 *
 * @param tx 未签名交易
 * @param inIndex 签名的输入索引
 * @param utxo inIndex 对应的 UTXO 信息, 包括 script、value、type
 * @param sigHashValue 哈希类型
 * @returns tx
 */
export const sigMsgForSignature = (
  tx: Transaction,
  inIndex: number,
  utxo: UTXO,
  sigHashValue: number
) => {
  // 复制 tx
  const txTemp = tx.clone()
 
  // sigHash === SIGHASH_NONE
  if ((sigHashValue & 0x1f) === Transaction.SIGHASH_NONE) {
    // 清空输出
    txTemp.outs = []
 
    // 清空非签名输入的 sequence
    txTemp.ins.forEach((input, i) => {
      if (i === inIndex) return
      input.sequence = 0
    })
  } else if ((sigHashValue & 0x1f) === Transaction.SIGHASH_SINGLE) {
    // sigHash === SIGHASH_SINGLE
 
    // 如果输入索引大于输出数量, 抛出异常
    if (inIndex >= txTemp.outs.length) {
      throw new Error('Input index is out of range')
    }
 
    // 移除多余的输出
    txTemp.outs.length = inIndex + 1
 
    // 设置非签名输入的索引对应的输出的 amount 为最大值, `scriptPubKey` 为空
    for (let i = 0; i < inIndex; i++) {
      ;(txTemp.outs as any)[i] = BLANK_OUTPUT
      ;(txTemp.outs as any)[i] = {
        script: new Uint8Array(0),
        valueBuffer: fromHex('ffffffffffffffff')
      }
    }
 
    // 清空非签名输入的 sequence
    txTemp.ins.forEach((input, i) => {
      if (i === inIndex) return
      input.sequence = 0
    })
  }
 
  if (sigHashValue & Transaction.SIGHASH_ANYONECANPAY) {
    console.log(`SIGHASH_ANYONECANPAY: ${sigHashValue} \n`)
 
    // 只保留当前要签名的输入
    txTemp.ins = [txTemp.ins[inIndex]]
    // 设置 `scriptSig` 为 `scriptPubKey`
    txTemp.ins[0].script = Buffer.from(utxo.script, 'hex')
  } else {
    // SIGHASH_ALL
 
    // 所有输入的 `scriptSig` 字段设置为空
    txTemp.ins.forEach((input) => {
      input.script = Buffer.from('')
    })
    // 设置当前要签名的输入的 `scriptSig` 为 `scriptPubKey`
    txTemp.ins[inIndex].script = Buffer.from(utxo.script, 'hex')
  }
 
  return txTemp
}

隔离见证V0

隔离见证V0锁有 P2WPKHP2WSHP2SH-P2WSH

BIP143 定义了隔离见证交易的签名规则, 如果要签名的交易输入的 UTXO 锁是隔离见证V0类型 则首先需要分别计算下列数据:

  • hashPrevouts - sigHash 不包含 SIGHASH_ANYONECANPAY 时存在, hashPrevouts 等于所有交易输入的 txidvout 拼接后进行 Hash256 计算

  • hashSequence - sigHash 等于 SIGHASH_ALL 时存在, hashSequence 等于所有交易输入的 sequence 拼接后进行 Hash256 计算

  • hashOutputs

    • sigHash 不包含 SIGHASH_SINGLESIGHASH_NONE 时, hashOutputs 等于所有交易输出的 amountscriptPubKeySizescriptPubKey 拼接后进行 Hash256 计算
    • sigHash 等于 SIGHASH_SINGLE 且要签名的交易输入索引小于输出数量时, hashOutputs 等于要签名的交易输入索引对应的交易输出的 amountscriptPubKeySizescriptPubKey 拼接后进行 Hash256 计算

其次依次拼接:

  • 交易的版本号 version
  • hashPrevouts
  • hashSequence
  • 要签名的交易输入的txid
  • 要签名的交易输入的vout
  • scriptCode 的字节大小
  • scriptCode: 如果是 P2WPKH 锁, 则提取出公钥哈希转换为 P2PKH 锁定脚本作为 scriptCode; 如果是 P2WSH 锁, 则提取出 redeemScript 作为 scriptCode
  • 花费的 UTXO 的金额
  • 要签名的交易输入的sequence
  • hashOutputs
  • 交易的 lockTime

具体见代码 signature.ts

隔离见证V1

隔离见证V1锁有 P2TR

BIP341 定义了 Taproot 签名规则, 如果要签名的交易输入的 UTXO 锁是 P2TR, 则首先需要分别计算下列数据:

  • hashPrevouts - sigHash 不包含 SIGHASH_ANYONECANPAY 时存在, hashPrevouts 等于所有交易输入的 txidvout 拼接后进行 sha256 计算
  • hashAmounts - sigHash 不包含 SIGHASH_ANYONECANPAY 时存在, hashAmounts 等于所有交易输入的 Amount 拼接后进行 sha256 计算
  • hashScriptPubKeys - sigHash 不包含 SIGHASH_ANYONECANPAY 时存在, hashScriptPubKeys 等于所有交易输入的 scriptPubKeySizescriptPubKey 拼接后进行 sha256 计算
  • hashSequences - sigHash 不包含 SIGHASH_ANYONECANPAY 时存在, hashSequences 等于所有交易输入的 sequence 拼接后进行 sha256 计算
  • hashOutputs
    • sigHash 不包含 SIGHASH_SINGLESIGHASH_NONE 时, hashOutputs 等于所有交易输出的 amountscriptPubKeySizescriptPubKey 拼接后进行 sha256 计算
    • sigHash 等于 SIGHASH_SINGLE 且要签名的交易输入索引小于输出数量时, hashOutputs 等于要签名的交易输入索引对应的交易输出的 amountscriptPubKeySizescriptPubKey 拼接后进行 Hash256 计算
  • spendType - 花费方式
    • 0 - 秘钥路径花费, 且没有附加数据 annex
    • 1 - 秘钥路径花费, 且有附加数据 annex
    • 2 - 脚本路径花费, 且没有附加数据 annex
    • 3 - 脚本路径花费, 且有附加数据 annex

其次依次拼接:

  • 交易的版本号 version
  • 交易的 lockTime
  • hashPrevouts
  • hashAmounts
  • hashScriptPubKeys
  • hashSequences
  • hashOutputs
  • spendType
  • sigHash 包含 SIGHASH_ANYONECANPAY 时, 继续拼接
    • 签名的交易输入的 txid
    • 签名的交易输入的 vout
    • 签名的交易输入的 Amount
    • 签名的交易输入的 scriptPubKeySize
    • 签名的交易输入的 scriptPubKey
    • 签名的交易输入的 sequence
  • sigHash 包含 SIGHASH_ANYONECANPAY 时只拼接签名的交易输入的索引

具体见代码 signature.ts

3. 数据签名

P2TR 使用 Schnorr 签名, 其他类型的锁定脚本使用 ECDSA 签名。

ECDSA 签名

对于非 P2TR 类型的锁定脚本使用 ECDSA 签名。签名前需要在上一步构造的未签名数据末尾添加4字节小端序签名哈希类型, 并进行 Hash256 运算。将运算结果作为 ECDSA 签名的消息。

import ecc from '@bitcoinerlab/secp256k1'
import ECPairFactory from 'ecpair'
import { hash256 } from 'bitcoinjs-lib/src/crypto'
 
const ECPair = ECPairFactory(ecc)
 
const keypair = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex'))
const hashedTransaction = hash256(Buffer.from(waitingForSignTx, 'hex'))
 
// ecdsa 签名结果
const ecdsaSignature = keypair.sign(hashedTransaction)

ECDSA 签名结果是一个 64 字节的数据, 前 32 字节是 r, 后 32 字节是 s

验证时通过公钥和签名数据计算得到的 r 是否与签名数据中的 r 相同, 若相同则签名有效。

因此实际的交易中, 凡是用到 ECDSA 签名的脚本, 都需要提供公钥。

例如:

  • P2PK 将公钥存储在锁定脚本中, 解锁脚本只需要包含签名数据
  • P2PKHP2WPKH 将公钥和签名数据都放在解锁脚本中
  • P2MS 将多签用到的公钥都放在了锁定脚本中

Schnorr 签名

P2TR 使用 Schnorr 签名。签名前需要在上一步构造的未签名数据前添加 1 字节签名哈希类型。并计算 tapKeyHash 作为 Schnorr 签名的消息。

Schnorr 签名的私钥不是原始私钥, 而是 Taproot 私钥。

Taproot 私钥

Taproot 私钥, 也称 Tweaked Private Key。等于原始私钥加上 tweak 值。

import { networks, type Signer } from 'bitcoinjs-lib'
 
const keyPair = ECPair.fromWIF(process.env['SECRET_KEY']!, network)
 
function tweakSigner(signer: Signer, opts: any = {}): Signer {
  let privateKey: Uint8Array | undefined = signer.privateKey!
  if (!privateKey) {
    throw new Error('Private key is required for tweaking signer!')
  }
  if (signer.publicKey[0] === 3) {
    privateKey = ecc.privateNegate(privateKey)
  }
 
  const tweakedPrivateKey = ecc.privateAdd(
    privateKey,
    tapTweakHash(toXOnly(signer.publicKey), opts.tweakHash)
  )
  if (!tweakedPrivateKey) {
    throw new Error('Invalid tweaked private key!')
  }
 
  return ECPair.fromPrivateKey(tweakedPrivateKey, {
    network
  })
}
 
function tapTweakHash(pubKey: Buffer, h: Buffer | undefined): Buffer {
  return crypto.taggedHash(
    'TapTweak',
    Buffer.concat(h ? [pubKey, h] : [pubKey])
  )
}

最后使用 tweakSignertapKeyHash 签名得到签名数据

import { taggedHash } from 'bitcoinjs-lib/src/crypto'
 
const keypair = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex'))
 
const tapKeyHash = taggedHash(
  'TapSighash',
  Buffer.concat([Buffer.from([0x00]), Buffer.from(witnessV1Msg.sigMsg, 'hex')])
)
 
const tweakedSigner = tweakSigner(keypair, NETWORKS[network].network)
 
const signature =
  tweakedSigner.signSchnorr && tweakedSigner.signSchnorr(tapKeyHash)

4. 签名数据编码

ECDSA 签名

ECDSA 签名数据需要进行 DER 编码, 生成最终的签名数据。

假设 ECDSA 签名数据是:

039d8fcf8bd43c2eba1ca16ad2512e33892994745220464164dd7550886a4f961d056caebb71ab54aedf29143b6416a5e482d3d840c0dab48abbd34efef53032
 
r=039d8fcf8bd43c2eba1ca16ad2512e33892994745220464164dd7550886a4f96
s=1d056caebb71ab54aedf29143b6416a5e482d3d840c0dab48abbd34efef53032
  • rs 前添加类型字节 0x02 (表示整数) 和长度字节 0x20
r:
02 20 039d8fcf8bd43c2eba1ca16ad2512e33892994745220464164dd7550886a4f96
 
s:
02 20 1d056caebb71ab54aedf29143b6416a5e482d3d840c0dab48abbd34efef53032
  • 组合 rs 后并在前添加整体长度字节 0x44 和 标识符字节 0x30
30440220039d8fcf8bd43c2eba1ca16ad2512e33892994745220464164dd7550886a4f9602201d056caebb71ab54aedf29143b6416a5e482d3d840c0dab48abbd34efef53032
  • 末尾添加签名使用的哈希类型
30440220039d8fcf8bd43c2eba1ca16ad2512e33892994745220464164dd7550886a4f9602201d056caebb71ab54aedf29143b6416a5e482d3d840c0dab48abbd34efef5303201

最终的签名数据大小为 71 字节

Schnorr 签名

Schnorr 签名数据不需要进行 DER 编码, 当 SigHash 不是 SIGHASH_DEFAULT 时, 需要在签名数据后添加 1 字节的SigHash

所以 Schnorr 签名数据是大小通常是 64 或 65 字节

5. 解锁脚本

根据要花费的 UTXO 的锁定脚本类型, 将签名数据以及其他数据联合生成解锁脚本。

例如:

P2PKH

要花费的 UTXOP2PKH 类型, 则解锁脚本格式为:

OP_PUSHBYTES_71 <签名数据> OP_PUSHBYTES_33 <公钥>

并放入对应交易输入的 scriptSig 字段。

P2WPKH

要花费的 UTXOP2WPKH 类型, 则将签名数据和公钥放入对应交易输入索引的 witness 字段。

P2TR

要花费的 UTXOP2TR 类型且是秘钥路径, 则将签名数据直接放入对应交易输入索引 witness 字段。

如果是脚本路径花费, 则除了签名数据外, 还需要使用的脚本和控制块一并放入 witness 字段。

Copyright © 2024 HeapUp