Develop with pleasure!

福岡でCloudとかBlockchainとか。

Dust limitを悪用したLightningプロトコルの脆弱性の開示

LNの実装に影響を与える仕様レベルの脆弱性が公開されていたので内容を見てみよう↓

[Lightning-dev] Full Disclosure: CVE-2021-41591/ CVE-2021-41592 / CVE-2021-41593 "Dust HTLC Exposure Considered Harmful"

前提

まず、脆弱性の内容を理解するために、前提となる知識について。

dust limit

dust limitは、Bitcoinの1stレイヤーに組み込まれているポリシーで、この設定値を下回る量が設定されたアウトプットを持つトランザクションはリレーしないというもの。そのアウトプットを使用するために、そのアウトプットが持つ金額以上の手数料が必要になるような支払いは誰もしないよねと。そういうアウトプットをdustと呼んでいる。

dust limitはアウトプットのタイプによって異なり、その計算方法は、そのアウトプットのサイズと、そのアウトプットを使用する際に必要なインプットのサイズから以下のように算出される

(アウトプットのサイズ + インプットのサイズ) * dust relay fee / 1000

dust relay feeはkBあたりのdustの定義値でデフォルト値は3000。このデフォルト値で計算した各アウトプットタイプのdustは以下のようになる↓

タイプ アウトプットのサイズ インプットのサイズ dust
非Segwit 34 148 546
P2WPKH 31 67 294
P2WSH 43 67 330

各タイプ毎にこれ未満の値がアウトプットに設定されているトランザクションはリレーされない。

この制限は、小額のUTXOを大量につくってBitcoinのUTXOセットを肥大化させフルノードの運用コストを上げるようなことを防止する目的で導入されている。

Lightningでのdust limitに関する処理

Lightningでもオフチェーン取引の際に作成されるコミットメントトランザクションについて、各アウトプットの量は↑のdust limit未満にならないようになっている。

まずチャネルを開設する際に、open_channel/accept_channelメッセージで互いにdust limitを通知するようになっており、この値はチャネルのライフタイム中は固定される。

そして、コミットメントトランザクションを作成する際は、交換したこのdust limitがそれぞれのコミットメントトランザクションに適用される。

つまり、以下の条件では、コミットメントトランザクションの該当するアウトプットが作成されない↓

  • to_localのコインの量がdust limitより少ない場合、to_localアウトプットは作成されない。
  • to_remoteのコインの量がdust limitより少ない場合、to_remoteアウトプットは作成されない。
  • HTLCについては、HTLCの額 - HTLC(success/timeout)の手数料が、dust limitより少ない場合、そのHTLCのアウトプットは作成されない。

このように、dust limitを下回る額のアウトプットはコミットメントトランザクションでは表現されず(作成されず)、コミットメントトランザクションの手数料として扱われる。このようなアウトプットをトリムされたアウトプットと呼ぶ。

脆弱性の内容

BOLTの仕様では、↑のチャネル開設時に設定するdust limitの値は、チャネルリザーブ額未満であれば良いというルールしかない。これらの2つのパラメータは任意に設定できるため、各実装で許可されている最大値までdust limitを増やすことができる(LNDの場合チャネルキャパシティの20%まで、LDKの場合はキャパシティの100%までみたい)。

そのため、dust limitを大きめの値に設定すると、それ未満のHTLCはコミットメントトランザクションからトリミングされてしまう。また、コミットメントトランザクション内に処理中のHTLCが複数含まれる場合、全HTLCの総額に対してではなく各HTLC毎にこのdust limitチェックおよびトリミングがされる。なお、dustによるトリミングは、チャネルの開設者がコミットメントトランザクションの手数料を更新するためのfee_updateメッセージの配信によっても発生する可能性がある。

これを悪用すると、意図的に上限いっぱいのdust limitを設定し、保留中のHTLCがありそれがトリミングされている場合に、そのコミットメントトランザクションがブロードキャストされると、トリミングされたコインを手数料として焼却することができる。また、それがマイナーであれば、その分をマイニング手数料として入手することができるというのが、今回の脆弱性の内容。

このdustに関するHTLCの処理は以前から課題として挙がっており、2019年から議論、調査、緩和策の設計、実装が進められていた模様。

対応

上記の脆弱性に対して、提案されている緩和策が↓

  • チャネル開設時にお互いに通知されたdust limit(dust_limit_satoshis)の値をチェックし、大きすぎる場合、チャネル開設を拒否するという修正(#894)。
  • 新しい制限値max_dust_htlc_exposureを導入し、HTLCが発生が発生した際に、そのHTLCのコインの量 + コミットメントトランザクションのdust額の総計がこの制限値を超える場合、そのHTLCを失敗させるという修正(#919

各ノードの対応状況

各ノードへのこの脆弱性のCVEと、パッチ適用バージョンは↓

  • Eclair: v0.6.2+ (CVE-2021-41591)
  • LND: v0.13.3+ (CVE-2021-41593)
  • LDK: v0.0.102(Rust Lightningベースの開発キットで、まだプロダクション用のソフトウェアとしてはリリースされていない)
  • c-lightning v0.10.2 (CVE-2021-41592)

現時点(2021/10/06)でパッチ適用済みのリリースがされてるのはLNDのみ(↑の2つの緩和策が実装されてる)っぽい。

アップデートせずにできる緩和策としては、現在開いているチャネルに大きな値のdust limitが設定されているか確認し、あればそのチャネルとのmin_htlc_msatをそれ以上の額で更新すれば、トリミングされるHTLCはなくなる。

LNDで実装されている経路探索アルゴリズム

LNを利用したオフチェーン支払いをする際、基本的に送信者が受信者までの支払いの経路を決めるようになっている。つまり、送信者はこの経路を探索する必要がある。LNのゴシップネットワークで、LNノードやそのチャネルの情報が(更新を含めて)配信されるので、ノードはローカルでそれらの情報を保持し、チャネルグラフを構成し、その情報を元に受信者までの経路を探索/選択する。

LNDだと、lncliでdescribegraphコマンドを実行すると、ノードが保持するネットワークグラフのデータ=ノード情報(nodes以下)とチャネル情報(edges以下)が取得できる↓

$ ./lncli describegraph
{
    "nodes": [
        {
            "last_update": 1632624677,
            "pub_key": "02ec095bfb29e486b814b61bcad294224597f7b890fd977149bdc1b2718210d088",
            "alias": "techmedia",
            "addresses": [
                {
                    "network": "tcp",
                    "addr": "gkckd5v6gl4g4aeauttbds33r64b4loyml7kupfs3godshqpd6wtogqd.onion:9735"
                }
            ],
            "color": "#00aeef",
            "features": {
                ...
            }
        ],
...
    "edges": [
        {
            "channel_id": "759077539161571329",
            "chan_point": "5cc1a55ab820619a4c3455539e576b80f8d942cc8df0050958637d7d28abdd5d:1",
            "last_update": 1632608086,
            "node1_pub": "02ec095bfb29e486b814b61bcad294224597f7b890fd977149bdc1b2718210d088",
            "node2_pub": "035b1ff29e8db1ba8f2a4f4f95db239b54069cb949b8cde329418e2a83da4f1b30",
            "capacity": "10000000",
            "node1_policy": { ... },
            "node2_policy": { ... }
        },
...

通常、Lightningの共通仕様はBOLTで定義されているが、↑の情報からどう経路探索/選択するかについては定義されていない。これは、経路探索が、外部のノードと対話する必要なく送信ノードが単独で計算できる類のものであるため。

経路探索

では、経路探索とは具体的に何をしているのか?

↑のデータのedgesのデータからもわかるように、チャネルの情報には以下のようなデータが含まれる。

  • capacity:チャネルキャパシティ
  • チャネルに参加している各ノードのポリシー:
    • fee_base_msat:支払いで発生する固定手数料
    • fee_rate_milli_msat:支払いで発生する支払額に応じた手数料
    • time_lock_delta:HTLCに設定されるタイムロックの差分
    • min_htlc:転送可能なHTLCの最小値
    • max_htlc_msat:転送可能なHTLCの最大値

経路探索とは、送信ノードから受信ノードまでの経路を見つけることだが、その際に送金額以上のキャパシティを持っているチャネルであり、かつ上記のノードポリシーから分かる手数料を加味した上で、安価に送金できる経路を探索する。

LNのネットワークは、グラフ理論を使って表現できる。グラフは頂点の集合とエッジの集合で構成される。LNの場合、各LNノードがグラフ上の各頂点で、チャネルを開設している2つのノード間にエッジが結ばれる。LNDでは、エッジに対して、支払いを転送するノードの以下のデータを重みとして設定する。

  • ルーティング手数料
  • タイムロックの差分
  • 確率

手数料とタイムロックの値は↑のデータから分かるようにチャネルの各ノードポリシーに設定されている値。最後の確率は、送信ノードから見て、そのエッジの送信成功確率になる。この確率は、実際に支払いをその経路で試行して、その結果(成功/失敗)を追跡し、その経路で支払いが成功するかどうかを表す確率パラメータになる。LNDではMission Controlというサブシステムでこれらの支払いの試行結果のトラッキングを行っている。

f:id:techmedia-think:20210928131907p:plain

このような重み付きの有向グラフをフローネットワークと呼ぶ。そしてこのようなネットワークにおいて、グラフ上の2つの頂点を結ぶ経路から重みが最小(=LNの場合、手数料が最も安価)な経路を求めるのが最短経路問題で、ダイクストラ法(Dijkstra法)は、重みが非負であるグラフ*1の最短経路問題を解くアルゴリズムの1つ。

LNDはこのダイクストラ法を変形版を使って経路を探索している。具体的には、↑のルーティング手数料とタイムロックの差分の両方を最小化し、↑の確率を最大化する経路を見つけるようになっている。実装されてるコードはこちら

支払い精度の向上

↑のように経路を探索/選択して支払いを実行しても、必ずしもその支払いが成功する保証はない。

  • 経路上のノードが支払いのタイミングで反応しない(オフラインになっているなど)
  • チャネル上には十分なキャパシティを持っているが、チャネルのHTLCを転送する側のノードに十分なローカルキャパシティが無い。例えば、チャネル参加者AとBについて、B→Aへの転送はB側に送金額より大きい残高があるので成功するが、A→Bの転送はA側の残高が送金額に満たず失敗する。

後者は、二者間のチャネル残高の配分はLNのゴシップネットワークに配信されないため、それを加味して経路を探索することはできない。これらの情報はプライバシーと、スケーラビリティの問題からおそらく今後も配信されることはないだろう。また、配信されたとしても支払いの瞬間に本当にその残高があると保証できるものでもない。

こういった不確実性がある中で、支払いの成功率を向上させるためには、↑のMission Controlのような試行結果によるフィードバックも精度の向上に繋がるが、このデータ自体が時間に敏感なデータなため、支払いを頻繁に多数していない状況だと効果は限定的になると思う。

他には、AMPのように小額を複数の経路で支払うことで、転送方向のキャパシティの制限を受けにくくすることで精度を向上させるというアプローチもあるけど、厳密にアトミックにするとどれか1つの経路が失敗したら、結局失敗してしまうことになるので、Boomerangのように冗長パスを含めるなど、他の改善との組み合わせも必要になるかもしれない。2017年にローンチされた直後と違い、ノードの参加数が大幅に増える中で、どう支払いの精度を上げるかは重要な課題の1つ。

*1:現状、LNではマイナスの手数料は存在しない。

TaprootとTLUV opcodeを利用したCovenantsの新しい実現方式

最近、Anthony TownsによってCovenantsの新しい実装方式を提案されている↓

これまでの提案

Covenantsとは、コインの使用方法に制約を加えるコントラクトの一種で、2016年からこれまで以下のような方式が提案されてる:

  1. Emin Gun SirerらによるBitcoin Covenants
    (新しいopcode OP_CHECKOUTPUTVERIFYを導入する方式)
  2. ElementsのOP_CHECKSIGFROMSTACKとOP_CATを使った方式
  3. Jeremy RubinによるOP_CHECKTEMPLATEVERIFYを使った方式(BIP-119)

いずれも、新しいopcodeの導入を必要とするため、既存のBitcoinではまだ利用できない。

TaprootとOP_TLUVを利用した方式

今回提案されているのは、Taprootのスクリプトツリーと新しい2つのopcodeによりCovenantsを実現する新しい方式。

TaprootとTapscriptの基本的な構成

プロトコルを理解するためには、まずTaprootやTapscriptに関する前提知識が必要だが、簡略化すると以下のようにロックスクリプトを構成する(詳細はGBEC動画を参照:TaprootTapscript)。

Taprootのロックスクリプトの主体は、内部公開鍵(P)とスクリプトで構成されるマークルツリーのルートハッシュ(S)から計算した

Q = P + H(P || S)G

になる。例えばスクリプトの条件が5つ(A, B, C, D, E)ある場合、以下のようなツリーとSの値になる。

                   S = H_taptweak(P || ABCDE)
                            |
                    H_tapbranch(ABCDE)
              /                            \ 
             /                             H_tapbranch(CDE)
            /                           /                     \
      H_tapbranch(AB)            H_tapbranch(CD)        H_tapleaf(E)
      /            \             /             \
H_tapleaf(A)  H_tapleaf(B)  H_tapleaf(C)  H_tapleaf(D)

Qにロックされているコインを条件Eのスクリプトを使ってアンロックする場合、トランザクションインプットのwitnessに以下のデータを提供する:

TLUV

Covenantsを実現するにあたっては、TAPLEAF_UPDATE_VERIFYIN_OUT_AMOUNTという2つの新しいopcodeを導入する。

TAPLEAF_UPDATE_VERIFY

TAPLEAF_UPDATE_VERIFY(TLUV)は、使用するインプットに対応する(同じインデックスの)アウトプットに対して、適用するscriptPubkeyを強制するopcodeになる。

つまりアウトプットのscriptPubkeyがインプットのscriptPubkeyと同じか、または条件をドロップしたり、追加したりしたスクリプトツリーで構成されるscriptPubkeyと一致するかを検証する。

例えば、↑のようなA, B, C, D, Eという5つの条件を持つスクリプトがあり、Eの条件を使ってインプットをアンロックする場合、

  • それと同じスクリプトツリー(A, B, C, D, E)を持つscriptPubkeyであるか?または、
  • インプットのスクリプトツリーから、現在実行中のスクリプト(↑の場合E)を削除したスクリプトツリー(A, B, C, D)を持つscriptPubkeyであるか?または、
  • さらに、新しいスクリプト条件(F)を追加したスクリプトツリー(A, B, C, D, E, F)を持つscriptPubkeyであるか?

といったこを検証可能にする。

TLUV opcodeは入力として以下の3つの要素をスタック上から取得する↓

  1. 内部公開鍵の更新方法を指定する値
    (加算(減算)する公開鍵を指定:例えば↑のPからP' = P + Xに更新する場合はXを指定)
  2. マークルパスの新しい条件を指定する値
  3. 現在実行中のスクリプトをツリーから削除する、および/または削除するマークルパスを指定する値

例えば、0 0 0 TLUVというスクリプトの場合、インプットと全く同じscriptPubkeyをアウトプットに強制する。

0 F 0 TLUVというスクリプトの場合、新しいスクリプト条件Fを↑のスクリプトツリーに追加したscriptPubkeyを強制する。

<X> 0 0 TLUVというスクリプトの場合、P + Xを新しい内部公開鍵としてスクリプトツリーは同じままののscriptPubkeyを強制する。このときXは32バイトのx-only public key。

0 F 2 TLUVというスクリプトの場合、現在実行中のスクリプトを削除し、新しいスクリプト条件Fをスクリプトツリーに追加したscriptPubkeyを強制する。ここで2の値は↑の3つめの制御用の数値で、この値の各ビット値は以下を示す:

  • 最下位ビットはx-only public keyであるXのy座標に関するパリティビット
    (y座標が偶数の場合は0、奇数の場合は1)
  • 次のビットは、現在のスクリプトをマークルパスからドロップするかどうかを示す値で、0であれば現在のスクリプトを維持し、1であれば現在のスクリプトをツリーから削除する。
  • それ以降のビット値は、ツリーから削除するマークルパスのステップ数。

2 = 10の場合、Xは今回未指定なので最下位ビット関係ないとして、次のビット値が1なので現在実行中のスクリプト(E)をツリーから削除する。

例えば、0 0 4 TLUVというスクリプトの場合、マークルパスの最後のステップ(CD)を削除した(Eの兄弟ノードを削除した)スクリプトツリーのscriptPubkeyを強制する。

IN_OUT_AMOUNT

2つめのIN_OUT_AMOUNT opcodeは、インプットのコインの量と対応するアウトプットのコインの量をスタックにプッシュする新しいopcode。

このopcodeをTLUVと他の算術演算のopcodeを組み合わせて使用することで、ルールを強制するアウトプットに対して適切なコイン量が保持されているかどうかを強制できる。

TLUVのユースケース

1つは、当初Covenantsが提案されていた頃からのユースケースだが、Vaultを実現することができる。また、その他に、単一のUTXOでマルチーパーティの資金を管理するCoinPoolを実現するための利用も期待されている。

所感

↑がTLUVを使った新しいCovenantsの実現方式。

  • IN_OUT_AMOUNTで数値演算する際に、現状のopcodeは32 bitの演算しかできないのをどうするか?
  • 内部公開鍵の更新にあたって、y座標が奇数になった場合にどうするか?

など課題はあるものの、Taprootのスクリプトツリーを使った条件の追加/削除をする構成はおもしろいアプローチだと思う。メーリングリストの投稿にも記載されてけど、現状は条件を予めすべて固定しておかなければならないが、OP_CATなどが有効になればよりダイナミックなCovenantsの構築も見えてくる。

Output Script Descriptorの基本仕様を定義したBIP-380

Output Script Descriptorは、Bitcoin Core v0.17からサポートされ始めた言語で、ウォレットやその他のプログラムが、自身が所有(関連)するUTXOを追跡するのに必要な情報を含むデータを定義する。

Bitcoinのウォレットの多くはHDウォレットをサポートしており、基本的には12個 or 24個からなるニーモニックをバックアップしておけば、マスターシードが復元でき、ウォレットが所有するUTXOを認識できる。

ただ、Segwit(P2WPKH、P2WSH)を始めマルチシグや、11月にアクティベート予定のTaprootへの支払いであるP2TRアドレスなど、1つの鍵が取るアドレス(アウトプット)の形態が増えてきたため、単純に鍵を復元しただけでは、どのアドレスを復元するのか判断できなくなっている。

そのため、ウォレット間のインターオペラビリティを担保するために、アウトプット(アドレス)の導出方法まで含めてウォレットのバックアップができるようにOutput Script Descriptorを利用しようと、今回新しくOutput Script DescriptorのBIPが定義された↓

https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki

BIP-380で定義されているのは、Output Script Descriptorの基本コンセプトと、ベースとなるScirpt Experession、Key Expressionとチェックサムの仕様について。その他のExpressionについては後続のBIPで定義されている。

まぁ、でもウォレットのエクスポート形式がOutput Script Descriptorになって、アドレス追加する度にウォレットのバックアップが必要になるのは、マスターシードさえバックアップしておけばいい現状の仕組みと比べて不便だな。それとも/*みたいな指定方法で回避するようになるのかな?

以下、BIP-380の意訳↓

概要

Output Script Descriptorは、Output Scriptのコレクションを記述するために使用できるシンプルな言語である。さまざまなdescriptorの断片や関数がある。このドキュメントでは、descriptorの一般的な構文、チェックサムおよび共通の式について記載する。

動機

Bitcoinウォレットは、従来、鍵のセットを保存していた。これらの鍵は、後でシリアライズされ、ウォレットが監視するOutpu Scriptやユーザーに提供するアドレスを生成するのに使われる。通常、バクアップは秘密鍵のみで構成され、最近では、BIP39のニーモニックの形で保存されている。しかし、Segregated Witnessが導入され、新たなアウトプットタイプが追加されてからは、このバックアップ方法では不十分だ。秘密鍵だけでは、復元されたウォレットがどのような種類のOutput Scriptやアドレスを生成するか知ることはできない。そのため、バックアップを復元したり、監視用ウォレットのデータをエクスポートする際に、ウォレット間で互換性がないという問題があった。

さらに問題を複雑にしているのがBIP32の導出パスだ。BIP44, 49, 84では、さまざまなOutput Scriptやアドレスのための標準的なBIP32導出パスが定義されているが、すべてのウォレットがそれらをサポートしている訳ではなく、それらの導出パスを使っているわけでもない。これらのバックアップやエクスポートに導出パスの情報が含まれていないため、ウォレット間の互換性が失われてしまう。

これらの問題に対する現在の解決策は、一般的なものではなく、レイヤー違反とみなすことができる。拡張鍵のシリアライゼーションに異なるversion byteを導入するような解決策は、レイヤー違反であり(鍵の導出はスクリプトタイプの意味とは分離されるべき)、特定の導出パスとスクリプトタイプにのみ特有のものだ。

Output Script Descriptorは、これらの問題に対する一般的な解決策を導入する。スクリプトタイプはScript Expressionを用いて明示的に指定される。鍵の導出パスはKey Expressionで明示的に指定する。これにより、作成するスクリプトやサブスクリプト(redeemScriptやwitnessScriptなど)および鍵を正確に指定したウォレットのバックアップおよびエクスポートを作成できる。本BIPで指定された一般的な構造により、新しいスクリプトタイプが追加されると、新しいScript Expressionを導入することができる。最後に、共通の用語と既存の規格を使用することで、Output Script Descriptorを技術的に読みやすくし、結果を一目で理解できるようにしている。

仕様

Descriptorはいくつかのタイプの式で構成される。最上位の式はSCRIPT。この式の後には#CHECKSUMが続く。ここでCHECKSUMは8文字の英数字のdescriptorのチェックサム

Script Expression

Script Expression(SCRIPTと表記)は、Bitcoinスクリプトに直接対応する式。これらの式は関数として記述され、引数を取る。このような式には、対応する引数を満たすスクリプトテンプレートがある。式は人が読める識別子文字列で書かれ、引数は括弧で囲まれている。識別子の文字列は英数字で、アンダースコアを含むことができる。

Script Expressionの引数は、その式自体によって定義される。Script Expression、Key Expressionまたはその他の式である可能性がある。

Key Expression

Script Expressionの引数としてよく使われる式に、Key Expression(KEYと表記)がある。これは公開鍵または秘密鍵と、オプションとしてその鍵のオリジンに関する情報を表す。Key Expressionは、Script Expressionの引数としてのみ使用することができる。

Key Expressionは以下の要素で構成される:

  • オプションで、以下で構成されるキーオリジンの情報:
    • 開き括弧[
    • 導出を開始する鍵のfingerprintを表す正確な8つのhex文字(詳細はBIP-32参照)
    • fingerprintとそれに続く鍵との間の、非強化導出または強化導出ステップを示す0個以上の/NUMもしくは/NUMhパス要素。
    • 閉じ括弧]
  • 次のいずれかである実際の鍵が続く:
    • hexエンコードされた公開鍵で、Script Expressionに応じて、次のいずれかになる:
      • 圧縮公開鍵を表す02または03で始まる66個のhex文字列
      • 非圧縮公開鍵を表す04で始まる130個のhex文字列
    • WIFエンコードされた秘密鍵
    • xpubエンコードされた拡張公開鍵、もしくはxprvエンコードされた拡張秘密鍵(詳細はBIP-32参照)
      • 拡張鍵の後に、実行されるBIP32導出ステップを示す0個以上の/NUMもしくは/NUMhパス要素が続く。
      • オプションで、最後のステップで単一の/*または/*hが続く。これはすべての直接的な非強化もしくは強化導出される子を示す。

KEYがBIP32拡張鍵の場合、Output Scriptを作成する前に、拡張鍵の後に続く導出情報を使って子鍵を導出する必要がある。最終ステップが/*または/*hの場合は、各子鍵のインデックス毎にOutput Scriptが作成される。導出した鍵は、非圧縮公開鍵としてシリアライズしてはならない。Script Expressionでは、導出した公開鍵をスクリプト作成のためどうシリアライゼーションするかについて、追加要件が定められている場合がある。

上記の仕様では、強化導出を示すhは、代替表現であるH'に置き換えることができる。

強化導出を伴うKey Expressionの正規化

秘密鍵なしでdescriptorがエクスポートされる場合、エクスポートされたdescriptorが有用であるためには、中間の強化導出ステップを削除するために追加の導出を行う必要がある。エクスポーターは、最後の強化導出ステップで拡張公開鍵を導出し、その拡張公開鍵をdescriptorの鍵として使用しなければならない。その鍵を導出するまでに行われた導出ステップは、前の鍵のオリジン情報に追加されなければならない。鍵のオリジン情報がない場合、新たに導出した拡張公開鍵のために追加しなければならない。最終的な導出が強化導出の場合、追加の導出は必要ない。

文字セット

descriptorで使用される式には、descriptorのチェックサムが機能するように、この文字セット内の文字のみが含まれている必要がある。

許可される文字は:

0123456789()[],'/*abcdefgh@:$%{}
IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~
ijklmnopqrstuvwxyzABCDEFGH`#"\<space>

最後の行の<space>はスペース文字であることに注意。

この文字セットは、以下のチェックサムがより多くのエラーを識別できるように、32文字の3つのグループとして特定の順序で書かれている。最初のグループは最も一般的な「保護されていない」文字(つまり、hexやkeypathなどの独自のチェックサムを持たないもの)。大文字小文字のエラーは32の倍数のオフセットを発生させるが、その一方で、前の制限に従い、できるだけ多くのアルファベット文字が同じグループに入っている。

チェックサム

トップレベルのScript Expressionの後には、単一のナンバー記号(#)とそれに続く8文字のチェックサムが続く。チェックサムはbech32と同様のエラー訂正チェックサムだ。

チェックサムには以下の特性がある:

  • descriptor文字列の間違いは、「シンボルエラー」で測定される。シンボルエラーの数が多いほど、検出が難しくなる:
    • 0123456789()[],'/*abcdefgh@:$%{}の文字を、そのセット内の別の文字に置き換えるエラーは、常に1シンボルエラーとしてカウントされる。
      • hexエンコードされた鍵はこれらの文字でカバーされることに注意してほしい。拡張鍵(xpubおよびxprv)は他の文字も使用するが、独自にチェックサムの仕組みもある。
      • SCRIPT式の関数名は、他の文字を使用する。これらの文字を間違えると、通常descriptorをパースできりなくなる。
    • 大文字小文字のエラーは常に1シンボルエラーとしてカウントされる。
    • その他の1文字の置換エラーは、1つまたは2つのシンボルエラーとしてカウントされる。
  • 1シンボルエラーは常に検出される。
  • 最大49154文字までのdescriptorの2または3シンボルエラーは常に検出される。
  • 最大507文字までのdescriptorの4シンボルエラーは常に検出される。
  • 最大77文字までのdescriptorの5シンボルエラーは常に検出される。
  • 最大387文字までのdescriptorで5シンボルエラーが検出されない可能性を最小限に抑えるよう最適化されている。
  • ランダムエラーは、 {1/2^{40}}で検出されない可能性がある。

チェックサム自体はbech32と同じ文字セットqpzry9x8gf2tvdw0s3jn54khce6mua7lを使用する。

チェックサム付き有効なdescriptor文字列は、以下のPython3のコードスニペットで指定された有効性の基準に合格する必要がある。descsum_check関数は、引数sSCRIPT#CHECKSUM形式で構成されるdescriptorである場合にtrueを返す。

INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
GENERATOR = [0xf5dee51989, 0xa9fdca3312, 0x1bab10e32d, 0x3706b1677a, 0x644d626ffd]

def descsum_polymod(symbols):
    """Internal function that computes the descriptor checksum."""
    chk = 1
    for value in symbols:
        top = chk >> 35
        chk = (chk & 0x7ffffffff) << 5 ^ value
        for i in range(5):
            chk ^= GENERATOR[i] if ((top >> i) & 1) else 0
    return chk

def descsum_expand(s):
    """Internal function that does the character to symbol expansion"""
    groups = []
    symbols = []
    for c in s:
        if not c in INPUT_CHARSET:
            return None
        v = INPUT_CHARSET.find(c)
        symbols.append(v & 31)
        groups.append(v >> 5)
        if len(groups) == 3:
            symbols.append(groups[0] * 9 + groups[1] * 3 + groups[2])
            groups = []
    if len(groups) == 1:
        symbols.append(groups[0])
    elif len(groups) == 2:
        symbols.append(groups[0] * 3 + groups[1])
    return symbols

def descsum_check(s):
    """Verify that the checksum is correct in a descriptor"""
    if s[-9] != '#':
        return False
    if not all(x in CHECKSUM_CHARSET for x in s[-8:]):
        return False
    symbols = descsum_expand(s[:-9]) + [CHECKSUM_CHARSET.find(x) for x in s[-8:]]
    return descsum_polymod(symbols) == 1

これにより、上記の特性を持つBCH符号が実装される。descriptor文字列全体が最初に処理されシンボルの配列になる。各文字のシンボルはそのグループ内での位置。3つめのシンボルごとにグループ番号を組み合わせた4つめのシンボルが挿入される。これは、グループ内の位置にのみ影響する変更、もしくはグループ番号の変更にのみ影響する変更は、単一のシンボルのみに影響することを意味する。

Script Expressionを指定して有効なチェックサムを作成する場合、以下のコードを使用できる:

def descsum_create(s):
    """Add a checksum to a descriptor without"""
    symbols = descsum_expand(s) + [0, 0, 0, 0, 0, 0, 0, 0]
    checksum = descsum_polymod(symbols) ^ 1
    return s + '#' + ''.join(CHECKSUM_CHARSET[(checksum >> (5 * (7 - i))) & 31] for i in range(8))

後方互換

Output Script Descriptorは、まったく新しい言語であり、既存のソフトウェアとは互換性がない。ただし式の多くのコンポーネントはこれまでのBIPで定義されたエンコーディングとシリアライゼーションを再利用する。

Output Script Descriptorは、将来の拡張用に、あらにセグメントタイプと新しいScript Expressionを使って設計されている。これらは追加のBIPに定義される。

参照実装

descriptorは、バージョン0.17以降のBitcoin Coreに実装されている。

Appendix A:式のインデックス

将来のBIPは、追加の式のタイプを指定する可能性がある。この表には、使用可能なすべての式タイプがリストされている。

名称 記述 BIP
Script SCRIPT 380
Key KEY 380
Tree TREE 386

Appendix B: Script Expressionのインデックス

Script Expressionは追加のBIPで定義される。この表は使用可能なScript Expressionとそれを定義したBIPのリスト。

BIP
pk(KEY) 381
pkh(KEY) 381
sh(SCRIPT) 381
wpkh(KEY) 382
wsh(SCRIPT) 382
multi(NUM, KEY, ..., KEY) 383
sortedmulti(NUM, KEY, ..., KEY) 383
combo(KEY) 384
raw(HEX) 385
addr(ADDR) 385
tr(KEY), tr(KEY, TREE) 386

楕円曲線のスカラー倍算アルゴリズム Part 2

前回の投稿では、楕円曲線スカラー倍算のアルゴリズムとしてバイナリ法やWindow法のアルゴリズムについて、また射影座標を用いた高速化手法について解説した↓

techmedia-think.hatenablog.com

今回は、もともとスカラー倍算の高速化について調査するきっかけとなったGLV法を用いた方法について。

GLVという名前は、発明者のRobert Gallant、Robert Lambert、Scott A. Vanstoneの3名の名前から来ており、自己準同型写像を持つ楕円曲線スカラー倍算を高速化する。このアルゴリズム特許が取られていたけど、それが2020年9月に有効期限切れになったため、Bitcoin Coreでもデフォルトで有効化された↓

github.com

検証自体は、故Hal Finneyにより2011年に行われ署名検証が約25%高速化すると報告されている。また、Pieter Wuille によりlibsecp256k1にも実装されたが、↑の特許のためデフォルトで無効化されていた。

GLV法

具体的に、GLV法ではどうやってスカラー倍算を高速化するのか見ていこう。この方法の解説は、Hal Finneyのbitcointalkへの投稿が分かりやすい(Hal Finneyが参考にしたのはScott A. Vanstoneが執筆したこの本)。

ここではスカラー値をk、乗算する点をQとする。つまりk*Qを計算する。

自己準同型の性質

secp256k1曲線は、楕円曲線上の任意の点Q(x, y)について、以下を満たす自己準同型の性質を持つらしい。

 {\lambda Q = (beta * x \mod p, y)}

ここで、pは楕円曲線のパラメータの1つでベースとなる有限体の素数 {\lambda, beta}は、それぞれ

  •  {\lambda = 0x5363ad4cc05c30e0a5261c028812645a122e22ea20816678df02967c1b23bd72}
  •  {beta = 0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee}

実際に↑の性質があるかは↓のように検証できる。

require 'ecdsa'
require 'bitcoin'

lambda = 0x5363ad4cc05c30e0a5261c028812645a122e22ea20816678df02967c1b23bd72
beta = 0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee

# 任意の点Qを作成
key = Bitcoin::Key.generate
Q = key.to_point

# 検証用にλQを計算
lambda_q = Q.multiply_by_scalar(lambda)

# beta * x mod p を計算
field = ECDSA::Group::Secp256k1.field
x = field.mod(beta * Q.x)

# λQが(beta * x \mod p, y)と同じであるかチェック
puts lambda_q.x == x && lambda_q.y == Q.y

結果、任意の点に対してこの性質が成立してるのが確認できる。

高速化

続いて、↑の性質を利用して、スカラー値kをλを使って以下を満たすnの約半分のビット長の {k_1, k_2}に分割する(nは曲線の位数)。

 {k = k_1 + k_2 * \lambda \mod n}

つまりk*Qは、

 {kQ = (k_1 + k_2 * \lambda)Q = k_1Q + k_2 * \lambda Q }

となる。ここで {Q' = \lambda Q}を先に計算しておく、つまり {kQ = k_1Q + k_2Q'}。このQ'の計算は {\lambda Q}を単純にスカラー倍算するのではなく、↑の特性を利用して {Q(x, y)}に対して {(beta * x \mod p, y)}を計算することで、つまりmod pの乗算を1回するだけで、効率的に計算することができる。

 {k_1, k_2}の分割方法

kをそれぞれ半分のビット長の {k_1, k_2}の分解するアルゴリズムは↑の書籍の、Algorithm 3.74 Balanced length-two representation of a multiplieで紹介されている。Hal Finneyの投稿では、以下の手順で計算している。

  • a1 = 0x3086d221a7d46bcde86c90e49284eb15
  • b1 = -0xe4437ed6010e88286f547fa90abfe4c3
  • a2 = 0x114ca50f7a8e2f3f657c1108d9d44cfd8
  • b2 = 0x3086d221a7d46bcde86c90e49284eb15

とし(a1=b2)、

  • c1 = (b2 * k / n.to_f).round
  • c2 = (-b1 * k / n.to_f).round
  • k1 = (k - c1 * a1 - c2 * a2) % n
  • k2 = (-c1 * b1 - c2 * b2) % n

のように分割する。後は、 {k_1Q + k_2Q'}を計算する。ここで、 {k_1, k_2}は、それぞれ半分のビット長であるため、計算する際のビット長が約半分になり、計算速度が向上すると。この計算量の削減によりスカラー倍算を高速化するというのがGLV法の仕組みっぽい。

Rubyでこのアルゴリズムを書くと↓のようになる。

require 'ecdsa'
require 'bitcoin'

beta = 0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee
n = ECDSA::Group::Secp256k1.order

q = Bitcoin::Key.generate.to_point

# beta * x mod n を計算
field = ECDSA::Group::Secp256k1.field
x = field.mod(beta * q.x)
# Q' = λQ
lambda_q = ECDSA::Point.new(ECDSA::Group::Secp256k1, x, q.y)

# k
k = 1 + SecureRandom.random_number(n - 1)
a1 = 0x3086d221a7d46bcde86c90e49284eb15
b1 = -0xe4437ed6010e88286f547fa90abfe4c3
a2 = 0x114ca50f7a8e2f3f657c1108d9d44cfd8
b2 = 0x3086d221a7d46bcde86c90e49284eb15

# kを分割
c1 = (b2 * k / n.to_f).round
c2 = (-b1 * k / n.to_f).round
k1 = (k - c1 * a1 - c2 * a2) % n
k2 = (-c1 * b1 - c2 * b2) % n

# k1Q + k2Q'を計算
glv = q.multiply_by_scalar(k1) + lambda_q.multiply_by_scalar(k2)

※ 厳密にはこのコードでは、最後のk1Q + k2Q'のmultiply_by_scalarを個別にやってるので速度は向上しない。速度向上させるためには、multiply_by_scalarの計算を1回のループで同時にやるよう修正する必要がある。