hiro99ma blog

何か技術的なこと

btc: BIP-341が難しい

2025/01/12

はじめに

年末から勉強がてら P2TR の署名を実装していた。

P2TR より前は単独鍵での署名とスクリプトでの署名が別々になっていた(P2PKH と P2SH、P2WPKH と P2WSHなど)。
署名というかアドレスというか、とにかくそういうやつだ(TxoutType)。

enum class TxoutType {
    NONSTANDARD,
    // 'standard' transaction types:
    ANCHOR, //!< anyone can spend script
    PUBKEY,
    PUBKEYHASH,
    SCRIPTHASH,
    MULTISIG,
    NULL_DATA, //!< unspendable OP_RETURN script that carries data
    WITNESS_V0_SCRIPTHASH,
    WITNESS_V0_KEYHASH,
    WITNESS_V1_TAPROOT,
    WITNESS_UNKNOWN, //!< Only for Witness versions not already defined above
};

P2TR ではアドレスとしては 1つの方式で、データについて単独鍵の key path とスクリプトの script path がある。
今はまだ単独鍵しか仕様を見ていないのだが、確か最後に署名するところまでに両立できるとか何とかだった気がする。

まあ、調べてないことを書くのはやめておこう。
ようやく keypath での署名が成功したので、忘れる前にメモしておこう。

勉強だからいろいろ実装しているけど、もし何かで使うなら実装されたライブラリを使うだろうね。

BIP-341

key path 関連の BIP はこんなところか。

シュノア署名自体は libsecp256k1 を使うし、bech32m は sipa/bech32 を使うので、BIP-340 と BIP-350 はほとんど見てない。
乱数生成やハッシュ値計算は libtomcrypt を使った。

自分で作るのは元になるデータを作ることくらいだ。
segwit より前は署名するトランザクションと鍵の情報があればなんとかなったが、今では input になるトランザクションの output 情報もないと署名できなくなった。
なので、データを集めるには Bitcoin ネットワークから input の情報を集めるという作業も発生するようになった。
まあ、勉強なのでそういうのは bitcoin-cli getrawtransaction などで予め取得すればよいだけだ。
真面目に P2P プロトコルまで手を出すといつまで経っても終わらんからね。

まだ key path の部分しか見ていないのだが、読んではつっかえ、読んではつっかえ。
そして実装しては失敗、という感じでなかなか進まなかった。 「失敗」といっても関数でエラーが起きるのではなく、regtest のトランザクションに署名して sendrawtransaction してようやく失敗がわかる感じだ。
署名して自分で実装した verify でもエラーになったのだけど、この段階だと自分の実装なんてまったく信用できないので意味が無い。

BIP-341 として提供されているテストデータ もあるのだが、真面目に BIP-341 を実装したときのデータなので key path だけという確認には使いづらかった。
merkleRootnull のデータがあるので一部は使えたのだが、sigMsg をもうちょっと詳しく!という状況だったので私にはうまく使うことができなかった。

いろいろ探して見つけたのがこちら。
それぞれの実データもあるので非常に助かった。

詳細はサイトを見てもらえば良いので、ここでは私が間違っていたところを残しておく。

署名で使うハッシュ計算

P2TR ではシュノア署名する。
他の output 形式でもそうだが、32バイトのデータを作って署名をしていた。
署名するデータの作り方は Common signature message に書いてある。
書いてあるとおりに SigMsg を作ってハッシュ計算して署名する。

Taproot key path spending signature validation のタイトルが「valildation」だったので、私は「ああここに書いてあるのは署名の検証についてなんだ」と考えた。
それは間違いではないのだが、署名については普通に SHA256 するだけだと思い込んでいた。
なので「なんで verify は別のやり方なんだろう・・・」と疑問を持っていた。
疑問というか、気付よって話だけど。
そう、署名する場合も SigMsg を同じやり方でハッシュ計算するのだ。当たり前なのだ。

hash_sighash(sigMsg) = SHA256( SHA256("TapSighash") || SHA256("TapSighash") || 0x00 || sigMsg)

テストデータの sigMsg の先頭が 0x00 であることに結構悩んだ末「これは verify で使うデータだ」ということにしてしまった自分が呪わしい。
その後、実装して引数に verify かどうかフラグまで追加したんだよ。
“common signature message” には先頭の 0x00 は入っていないのだが、何か意図があるのだろうか。

sigMsg

署名するハッシュ値の計算は確定した。
次はハッシュ計算するデータ sigMsg について。

Common signature message を読むと key path で普通に署名するとなるとこういうデータを連結することになる。
hash_type = 0x00(SIGHASH_DEFAULT), spend_type = 0x00 をイメージしている。

P2WPKH などでも似たようなことをやっているのだが、並びも中身も変わった。
まず、ハッシュ値を取っていたのが HASH256(SHA256 を 2回)から SHA256 だけになった。
詳しいことは分からないが、この使い方だと HASH256 にしても SHA256 とセキュリティ的に特に代わりが無いので SHA256 にしたという説明が書いてあった。

私がここで間違っていたのは sha_scriptpubkeys だった。
“all spent outputs’ scriptPubKeys” を署名するトランザクションの output だと考えたのだ。

the SHA256 of all spent outputs’ scriptPubKeys, serialized as script inside CTxOut.

実際は input になるトランザクションの vout にある scriptPubKey たちだった。
どっちかあやしいとは思ったのだが、Common Signature Message を見るまでは安心できなかった。
outpoint たちを prevouts と書くなら、amounts と scriptpubkeys も “prev” を付けてほしかったものだ。
だいたい outpoint なんてこのトランザクションに載ってるんだから “prevouts” よりも “outpoints” の方がわかりやすかろう。

と、自分の英語力を棚に上げることにする。

実データが 1. Key Path Spend の “Code” にあるのが助かった。
“scriptPubKey” といっても、実は witness program だけじゃないのかとか、0x5120 はいるのかとか、細かい悩みがあるのだ。
いるのは、データ長も含めた 0x225120 <witness program> である。

この辺が一番時間がかかった。
ハッシュ計算された後を比較しても、違うということがわかるだけで何が違うのか分からなかったからね。
実データ様々である。

pubkey の Y座標が奇数かどうか

tweak pubkey しかり tweak privkey しかり、pubkey の Y座標が奇数かどうかで処理が一手間かかるようになる。
libsecp256k1 で tweak pubkey を求めるのに使う secp256k1_xonly_pubkey_tweak_add() はそれを考慮するようにできていた。

しかし tweak privkey で使いそうな secp256k1_ec_seckey_tweak_add() にはそういう考慮が無い。

では pubkey の Y座標が奇数かどうか調べる関数があるかというと、ないようだ。
では Y座標を取得して最下位バイトだけチェックすればよいと思ったが、secp256k1_pubkey にはそういうデータがあるのかどうか分からんが、つまり直接この変数を使うことはできなさそう。
結局、secp256k1_pubkey をシリアライズして 33 バイトの圧縮公開鍵にした先頭バイトが 0x03 かどうかでチェックするようにした。
内部ではそういう static な関数を持っているのだけど、外側では使えなさそうなのだ。
bitcoind がどうやっているか見てみたいものだ。

おわりに

こういう「間違っているのは分かるけど何が間違っているのか分からない」というのは実装でひどく疲れる。
あれだ、ユーザ名とパスワードを入力して「違います」と言われるような感じだ。

やっぱり英語力の弱さが響いているのかなぁ。。。

< Top page