ビットコイン・インスティテュート

BIP 324 — P2P 暗号化トランスポートプロトコルバージョン 2

BIP: 324
  Layer: Peer Services
  Title: Version 2 P2P Encrypted Transport Protocol
  Authors: Dhruv Mehta <dhruv@bip324.com>
           Tim Ruffing <crypto@timruffing.de>
           Jonas Schnelli <dev@jonasschnelli.ch>
           Pieter Wuille <bitcoin-dev@wuille.net>
  Status: Deployed
  Type: Specification
  Assigned: 2019-03-08
  License: BSD-3-Clause
  Version: 1.0.2
  Replaces: 151

序論

概要

本文書は、日和見的暗号化、緩やかな帯域幅の削減、およびアプリケーションメッセージを交換する前にアップグレードを交渉する能力を特徴とする、新しいビットコイン P2P トランスポートプロトコルを提案する。

著作権

本文書は 3 条項 BSD ライセンスの下に提供される。

動機

ビットコインは、公開データに関する合意形成を目的とした、許可不要のネットワークである。ビットコイン P2P ネットワークで中継されるすべてのデータは本質的に公開であり、またプロトコルには暗号学的アイデンティティという概念が存在しないため、ピア同士は暗号化も認証もされていない接続の上で会話する。それでもなお、現行 P2P プロトコル(本文書では v1 と呼ぶ)のこの平文という性質は、攻撃者の存在下で深刻な欠点をもたらす。

  • 中継されるデータ自体は本質的に公開であるが、付随するメタデータは私的な情報を明らかにし、利用者のプライバシーを損ないうる。たとえば、すべてのビットコイン P2P 接続を盗聴するグローバルな受動的攻撃者は、あるトランザクションの発信元とタイミングを容易に特定できる。
  • 接続が認証されていないため、低いコストで、しばしば検知されるリスクも低いまま、改ざんされうる。たとえば、攻撃者は状態を保持する必要なく、接続の特定のバイト(ノードフラグなど)をその場で改変できる。
  • プロトコルは自己を露呈する。たとえば、接続が固定されたマジックバイトの並びで始まるため、ディープパケットインスペクションは P2P 接続を容易に識別できる。接続を検知する能力は検閲を可能にし、前述の攻撃や、被害者の接続を攻撃者が制御する必要のある他の攻撃(例: マイナーを標的とした日食攻撃)を助長する。

この新しい P2P プロトコルバージョン(v2)の提案は、主として認証なしの日和見的トランスポート暗号化を用いることで、これらの攻撃を実行するコストを大幅に引き上げ、この状況を改善することを目指す。加えて、通信路上のバイトストリームは、受動的な盗聴者に対して疑似乱数化される(すなわち一様乱数のバイト列と区別不能になる)。

  • 暗号化は、認証なしであり、両端点が v2 に対応する場合にのみ用いられるとしても、攻撃者を能動的にならざるを得なくすることで盗聴を妨げる — 持続的な中間者(MitM)攻撃を実行するか、接続を v1 にダウングレードさせるか、自身のノードを立ち上げて正直なノードにそれへ接続させるか、のいずれかである。大規模な能動攻撃は一般により多くの資源を要するが、(自動的でランダムな接続とは対照的な)手動で意図的な接続の場合、原理的に検知可能でもある。運用者がプロトコルバージョンとセッション ID(本提案のプロトコルが対応する)を手動で比較するような、ごく基本的な確認だけでも、攻撃者を露呈させる。
  • 改ざんは、それ自体すでに本質的に能動的な攻撃であるが、攻撃者が完全な MitM 傍受に必要な状態を維持することを強いられれば、より高コストになる。
  • 疑似乱数化されたバイトストリームは、パターンマッチングに基づく識別手法を排除し、インターネット上で使われる他のプロトコルを模倣するためにバイトストリームを成形することを容易にする。これは接続を検閲するファイアウォールのコストを引き上げ、完全な MitM 攻撃に頼るか、ブロックリスト方式ではなくより露骨な許可リスト方式で運用するかを強いる。

なぜ認証なしで暗号化するのか?

上述のとおり、認証なしの暗号化この文脈における認証とは何を意味するのか?残念ながら、セキュアチャネルプロトコルの文脈における認証という用語は曖昧である。これは以下を指しうる。

  • 復号に成功して得られたメッセージが、(対称)暗号鍵にアクセスできる者によって暗号化されたものであり、暗号化後に第三者によって改変されていないことを、暗号化方式が保証すること。本文書の提案は AEAD の使用を通じてこの性質を達成する。
  • 通信プロトコルが、公開鍵機構を通じて、通信相手のアイデンティティが期待どおりの相手と一致することを確立すること。本文書の提案には、そのような機構は含まれない。は、暗号化なしより厳密に優れたセキュリティを提供する。したがって、すべての接続は、認証されていなくとも、暗号化を用いるべきである。

認証に関しては、暗号化ほど状況が明確ではない。ビットコインの許可不要という性質上、認証は常に特定の状況(例: 同一運用者に属するピア間の接続)に限られ、何らかの形の(部分的に匿名でありうる)認証が望まれるかどうかは、関与するピアの具体的な要件に依存する。したがって、認証は(望まれる場合)別途扱われるべきであると考え、本提案は、認証の追加(プライベート認証プロトコル (https://github.com/sipa/writeups/tree/main/private-authentication-protocols)を参照)を含む将来のプロトコルアップグレードのための堅固な技術的基盤を提供することを目指す。

トラフィック分析がなお可能であるのに、なぜ疑似乱数のバイトストリームを持つのか?

パケット長やタイミングの観測などのトラフィック分析、および能動攻撃は、依然としてビットコイン v2 P2P プロトコルが使用中であることを明らかにしうる。それでもなお、疑似乱数のバイトストリームはプロトコルの指紋取得のコストを大幅に引き上げ、一部の仲介者に、識別できないあらゆるプロトコルを攻撃せざるを得なくさせ、巻き添えのコストを発生させうる。

疑似乱数のバイトストリームは自己を識別させない。さらに、これは特定の主張を持たず、したがって類似のプロトコルにとって規範的な選択となる。結果として、ビットコインの P2P トラフィックは、同じ選択をする他のプロトコル(例: obfs4 (https://gitlab.com/yawning/obfs4) や、最近提案された cTLS 拡張 (https://datatracker.ietf.org/doc/draft-cpbs-pseudorandom-ctls/))のトラフィックと区別不能になる。さらに、トラフィックシェイパーやプロトコルラッパー(たとえばトラフィックを HTTPS や SSH のように見せかけるもの)は、トラフィック分析や能動攻撃をさらに緩和できるが、本提案の範囲外である。

なぜセキュアトンネルプロトコルを使わないのか?

我々の目標には、大規模攻撃に対する最良の防御を提供する、日和見的暗号化を遍在させることが含まれる。これは、ノード運用者が自身のソフトウェアに指示して行わせる手動で意図的な接続と、ゴシップを通じて得た IP アドレスに基づいてビットコインノード同士が行う自動接続の、両方を保護することを意味する。暗号化それ自体はプロキシネットワークや VPN プロトコルによってすでに可能であるが、これらは大規模な自動接続にとって望ましくも適用可能でもない。

  • Tor や I2P のようなプロキシネットワークは、ネットワークトポロジーから独立した別個のアドレス空間を導入し、アドレスあたりの費用が非常に低いため、日食攻撃を安価にする。これに対し、クリアネットの IPv4 と IPv6 のネットワークでは、明確に区別された既知のネットワーク分割の中で複数のネットワークアイデンティティを取得することに無視できないコストがかかる。したがって、ノードのかなりの部分がこの方式のみで接続されることは望ましくない。これは日食攻撃のコストを大幅に引き下げてしまうためである。なぜノードを Tor のみで接続することは望ましくないのか? Bitcoin over Tor isn’t a Good Idea (https://arxiv.org/abs/1410.6079) という論文を参照。加えて、Tor 接続にはかなりの帯域幅とレイテンシのコストが伴い、すべてのネットワーク利用者にとって望ましいとは限らない。
  • WireGuard や OpenVPN のような VPN プロトコルは、本質的にプライベートネットワークを定義するものであり、手動の設定を必要とするため、自動接続にとって現実的な手段ではない。

したがって、我々の目標を達成するには、コストが最小限で、設定なしに動作し、常に有効である解決策が必要である — ネットワーク層の一部としてではなく、任意のネットワーク層の上に位置するものとして。

なぜ汎用のトランスポート暗号化プロトコルを使わないのか?

TLS や Noise のような既製のトランスポート暗号化プロトコルに依拠することも可能ではあるが、上述したビットコイン P2P ネットワークの具体的な要件により、これらのプロトコルは不適切な選択となる。

既存のプロトコルが満たせない主要な要件は、暗号化と認証の十分にモジュール化された扱いである。上述のとおり、ビットコイン P2P ネットワークにおいてどのような形の認証が望まれるか(そもそも望まれるか)は、関与するピアの具体的な要件に依存する(結果として認証済みと未認証の接続が混在することになる)ため、認証の問題は暗号化から切り離されるべきである。しかし、(デジタル署名や証明書を用いるなどの)一握りの標準的な認証シナリオへのネイティブな対応は、既存の汎用トランスポート暗号化プロトコルの設計の中核にある。この認証への注力は、ビットコイン P2P ネットワークにとって明確な利益をもたらさない一方で、大量の追加的な複雑さを伴う。

これに対し、我々の提案は、認証を別途扱うことを可能にする、単純でモジュール化された設計を目指す。我々の提案は、暗号化されたチャネルを一意に識別するセッション ID をエクスポートすることで、認証のための基盤を提供する。暗号化されたチャネルが確立された後、両端点は任意の認証プロトコルを用いて、同一のセッション ID を持つことを確認できる。(これはセッション ID が暗号化チャネルを認証プロトコルに結び付けることから、しばしばチャネルバインディングと呼ばれる。)我々の提案では、いかなる認証も暗号化された接続が確立された後に実行される必要があるため、このモジュール性の代償として、Diffie-Hellman 鍵交換と並行して認証を行う他のプロトコルと比べ、ラウンドトリップ数が増える可能性がある。他のプロトコルはセッション ID のエクスポートに対応していないのか? Noise (https://noiseprotocol.org/noise.html#channel-binding)TLS(草案として) (https://datatracker.ietf.org/doc/draft-ietf-kitten-tls-channel-bindings-for-tls13/) はセッション ID をエクスポートする同様のプロトコル拡張を提供しているが、認証にチャネルバインディングを用いることはそれらの設計の焦点ではなく、認証手法のネイティブサポートに由来する追加の複雑さの大部分を回避することにはならない。 しかし、その結果として生じる接続確立レイテンシの増加は、しばしば数時間、あるいは数週間続く (https://www.dsn.kastel.kit.edu/bitcoin/)ビットコインの長寿命な接続にとって懸念とはならない。

この認証の扱いが根本的に異なる点に加えて、TLS や Noise を我々の目的に適用する際には、さらなる技術的課題が生じる。

  • いずれも疑似乱数のバイトストリームを提供しない。
  • いずれも、ビットコインで他に使われている secp256k1 曲線上の楕円曲線暗号への、ネイティブな対応を提供しない。secp256k1 の使用は厳密に必要ではないが、暗号学的な困難性の仮定や、ビットコインソフトウェアが必要とする依存関係を最小化するため、ビットコインにおける新しい非対称暗号にとって明白な選択である。
  • いずれもバイトストリームの成形可能性を提供しない。
  • 両者ともアプリケーション層へストリームベースのインターフェースを提供するが、ビットコインはパケットベースのインターフェースを必要とするため、パケットのシリアライズ・デシリアライズを行う追加の薄い層が必要になる。

既存のプロトコルは、前述の課題すべてに対処するよう改変することも可能ではあるが、それは既製の解決策として使うことの利点(既存の実装やセキュリティ分析を再利用できる可能性など)を打ち消してしまう。

目標

本提案は以下の性質の達成を目指す。

  • 受動攻撃に対する機密性: v2 P2P バイトストリームを(タイミングとフラグメンテーションの情報なしに)記録した受動的攻撃者は、ノード間で交換されている平文を特定できてはならない。
  • 能動攻撃の観測可能性: 暗号化チャネルを一意に識別するセッション ID は、Diffie-Hellman 交渉から決定論的に導出される。能動的な中間者攻撃者は、ピアの運用者がセッション ID を手動で、または将来のプロトコルバージョンで導入されうる任意の認証手法を用いて比較できるため、検知されるリスクを負わざるを得ない。
  • 疑似乱数のバイトストリーム: v2 P2P バイトストリームを(タイミングとフラグメンテーションの情報なしに)記録した受動的攻撃者は、これを一様乱数のバイトストリームと区別できてはならない。
  • 成形可能なバイトストリーム: トラフィック分析への耐性を高める(たとえばブロック伝播を隠蔽する)ため、または検閲回避のために、バイトストリームを成形できるべきである。成形可能性はフラグメンテーションパターンに基づく検閲の回避にどう役立つか?例としてこの Tor issue (https://gitlab.torproject.org/legacy/trac/-/issues/20348#note_2229522) を参照。
  • 前方秘匿性: ピアのセッション秘密情報を侵害する盗聴者は、直近の数パケットを除き、過去のセッショントラフィックを復号できてはならない。
  • アップグレード可能性: 本提案は、将来、認証や PQC(耐量子暗号)ハンドシェイクのアップグレードなどの機能を追加するために用いることができる、トランスポートのバージョニングによるアップグレード経路を提供する。
  • 互換性: v2 クライアントは、ネットワーク分断のリスクを最小化するため、インバウンドの v1 接続を許容する。
  • 低オーバーヘッド: 新しい P2P トランスポートプロトコルの導入は、現行プロトコルと比較して、それを実装するノードの計算コストや帯域幅を大幅に増加させるべきではない。

仕様

本仕様は 3 つの部分から構成される。

  • トランスポート層は、2 個のノード間でアプリケーションレベルのメッセージを転送できる、暗号化された接続をどう確立するかを扱う。
  • アプリケーション層は、トランスポート層による転送のために、ビットコイン P2P メッセージとコマンドをどう符号化するかを扱う。
  • シグナリングは、v2 ノードが v2 プロトコルへの対応を潜在的なピアにどう告知するかを扱う。

トランスポート層仕様

本節では、ピア間のメッセージに対する暗号化プロトコルを定義する。

概観と設計

まず、プロトコル全体のフローとパケット暗号化について、非形式的な概観を示す。

プロトコルフローの概観

新しく確立された(通常は TCP/IP の)接続が 2 個の v2 P2P ノード間にある場合、その接続は 3 個のフェーズを経る。最初のフェーズは即座に開始する。すなわち、事前にリンク上で v1 メッセージや他のバイトが交換されることはない。両当事者は、(接続を確立した)イニシエーターと、(接続を受け入れた)レスポンダーと呼ばれる。

  1. 鍵交換フェーズ。ここでノードは、共有秘密を確立するためのデータを交換する。
  • イニシエーターは:
    • ランダムな一時 secp256k1 秘密鍵を生成し、対応する 64 バイトの ElligatorSwiftElligatorSwift とは何か、なぜそれを使うのか? SwiftEC 論文 (https://eprint.iacr.org/2022/759.pdf)は、楕円曲線上の点を、一様分布のビット列と区別不能な形で符号化する ElligatorSwift という手法を記述している。ランダムな 256 ビット文字列が secp256k1 曲線上の有効な X 座標である確率は約 50% であるのに対し、あらゆる 512 ビット文字列は曲線上の点の有効な ElligatorSwift 符号化であるため、一様にサンプリングできる符号化器を用いれば、符号化された点はランダムと区別不能になる。ElligatorSwift の速度はどの程度か?我々のベンチマークでは、ElligatorSwift で符号化された ECDH は、符号化されていない ECDH よりも約 50% 高価であることが示されている。ECDH の高速な性能と新規接続の低い頻度を踏まえ、疑似乱数バイトストリームと、それが可能にする将来の検閲耐性のためには、この性能上のトレードオフは許容できると判断した。符号化された公開鍵を、レスポンダーへ送る。
    • 公開鍵の後に、最大 4095なぜガーベジの上限を 4095 バイトとしたのか?これは、情報を隠すための十分な自由度を持たせることと、必要な 64 バイトの公開鍵がそれと比べて小さくなるほど十分大きくすること、その一方で帯域幅の浪費を抑えること、との間のバランスである。バイトの任意データ(ガーベジと呼ぶ)を送ってもよく、これは成形可能性の一形態を提供し、正確に 64 バイトという認識可能なパターンを回避する。なぜプロトコルにガーベジのための余地があるのか?公開鍵の後のガーベジ文字列は、ハンドシェイクの成形可能性のために必要である。いずれのピアも、少なくとも相手の公開鍵を受け取るまではおとりパケットを送れない。すなわち、いずれのピアも 64 バイトを受け取るまでは 64 バイトを超えて送ることができない。
  • レスポンダーは:
    • ネットワークマジックに続けて “version\x00\x00\x00\x00\x00” からなる 16 バイトと一致しない 1 バイトを受け取るまで待機する。先頭 16 バイトが一致する場合、その接続は代わりに v1 プロトコルを使っているものとして扱われる。v2 イニシエーターの公開鍵が偶然にもこの 16 バイトで始まってしまったらどうなるのか? v2 プロトコルにおいてこれがランダムに起こる確率(2-128)はあまりに低いため、イニシエーターはこれを特に回避する必要はない。誤ったネットワークの v1 ピアを検出する任意の仕組みの確率は 2-96 であり、これもランダムなネットワーク障害と比べて依然として無視できる。Bitcoin Core のバージョン 0.4.0 以下および 22.0 以上は、VERSION メッセージより前に受信した有効な P2P メッセージを無視する。0.4.0 から 22.0 の間のバージョンは、そのようなメッセージを受信するとピアに不正行動スコアを付与する。本提案を実装する v2 クライアントは、最初のメッセージとして受信した VERSION 以外のいかなるメッセージも v2 接続の開始であると解釈し、最初のメッセージとして VERSION 以外の型を送る v1 イニシエーターとの接続を切断することになる。これが問題となりうる実装は把握していない。
    • 受信した先頭 4 バイトがネットワークマジックと一致しないが、その後の 12 バイトが上記のバージョンメッセージの符号化と一致する場合、実装はこれを別ネットワークの v1 ピアであると解釈し、切断してよい。
    • 同様にランダムな一時秘密鍵を生成し、対応する 64 バイトの ElligatorSwift 符号化された公開鍵を、イニシエーターへ送る。
    • 同様に、自身の公開鍵の後に、最大 4095 バイトのガーベジデータを送ってよい。
  • 両当事者は:
    • 相手側から(残りの)完全な 64 バイトの公開鍵を受け取る。
    • X-onlyなぜ X-only ECDH を用いるのか? X 座標のみを用いることは、秘密の曲線上の点の完全な符号化を用いる場合と同じ安全性を提供しつつ、Y 座標を計算するための平方根を必要としないため、より効率的な実装を可能にする。 ECDH を用いて、自身の秘密鍵と交換された公開鍵から共有秘密を計算しなぜ共有秘密の計算は、送信された正確な 64 バイトの公開符号化の関数になっているのか?これにより、攻撃者はストリームの残りを変更せずに使用される公開鍵符号化を変更することができないことが保証される。第三者がストリームのバイトを変更する能力を得たいなら、当該接続に対して完全な MitM 攻撃を実行する必要がある。、その秘密から決定論的に、4 個の暗号鍵(各方向に 2 個ずつ: パケット長用が 1 個、内容の暗号化用が 1 個)、セッション ID 1 個、および 16 バイトのガーベジターミネーター 2 個ガーベジターミネーターに十分な長さはどれくらいか?ガーベジターミネーターの長さは、(ECDH に先立って送られる)ガーベジバイトが偶然にもターミネーターを含んでしまうことによる、正当な v2 接続の意図しない終端の確率を左右する。4095 バイトのガーベジに対する 16 バイトのターミネーターは、そのような衝突の確率を無視できる水準に抑え、それはインターネット上のランダムな接続障害よりも桁違いに低い可能性が高い。実際のガーベジターミネーターはどのようなものか? A cartoon illustration of a robot resembling a mailbox or trash compactor, with a glowing Bitcoin logo on its chest and one mechanical arm raised in a checkmark gesture, standing on a traffic cone next to a stack of open storage bins. (各方向に 1 個ずつ)を、HKDF-SHA256 を用いて導出する。
    • 自身の 16 バイトのガーベジターミネーターを送る。なぜプロトコルはガーベジターミネーターを必要とするのか?原理的には、ガーベジの直後の最初のパケットをそのままターミネーターとして用いる(有効なパケットが続くまで走査する)ことも可能ではあるが、これは受信したバイトごとに Poly1305 タグを再計算する必要があり、固定のバイト列を単に走査するよりも著しく遅い。
    • 最大 4111 バイトを受信し、ガーベジターミネーターに遭遇したところで停止する。
  • この時点で、両当事者は同じ鍵を持っており、以降のすべての通信は暗号化パケットの形で進行する。
    • 暗号化パケットは無視ビットを持ち、これが設定されているとおとりパケットとなる。おとりパケットは、正しく復号できることを検証する以外、受信者によって無視される。いずれのピアも、この時点以降いつでもそのようなおとりパケットを送ってよい。これらはプロトコルにおける主要な成形可能性の機構を形成する。それらをいつ・どう使うかは本文書の範囲外である。
    • 双方向のそれぞれについて、その方向で送られる最初の暗号化パケット(おとりパケットであるか否かを問わない)は、AEAD の関連付けられた認証データ(AAD)機能を用いて、その方向で送られたガーベジを認証する。なぜプロトコルはガーベジを認証するのか?ガーベジ認証がなければ、ガーベジは何の弊害もなく第三者によって改変されうる。我々は、あらゆる能動的攻撃者に、完全なプロトコル状態を維持することを強制したい。加えて、接続終了という帰結を伴わないそのような可鍛性は、プロトコルの指紋取得を可能にしうる。
  1. バージョン交渉フェーズ。ここで両者は、使用するトランスポートバージョン、およびそのバージョンで定義されるデータを交渉する。将来のプロトコルバージョンではどのような機能が追加されうるか?将来のバージョンで追加されうる機能の例には、ハンドシェイクへの耐量子暗号アップグレードや、任意の認証が含まれる。
  • レスポンダーは:
    • 内容が空のバージョンパケットを送り、本文書が提案する v2 P2P プロトコルへの対応を示す。内容のそれ以外の値は将来のバージョンのために予約されている。
  • イニシエーターは:
    • パケットを受け取り、その内容を無視する。将来のバージョンで追加される機能は、双方が対応している内容に基づいて交渉されるという考え方である。これまでのところバージョンは 1 つしかないため、ここでの内容は単に無視してよい。しかし将来的には、ここで空でない内容を受け取ると他の挙動を引き起こすかもしれない。そのようなバージョン内容の符号化を規定するのは、必要になるまで見送る。将来のバージョンはバージョンパケットにおいてバージョン番号をどう符号化するか?将来のバージョンは、たとえば、バージョンパケットの内容を整数のバージョン番号(空は 0 を表す)として解釈すると規定し、両者の番号の最小値が N であれば、それを「v2.N」プロトコルバージョンの選択として解釈することが考えられる。あるいは、バージョンパケット内容の特定のバイトを、任意機能のビットベクトルとして解釈することも考えられる。
    • 同様に、内容が空のバージョンパケットを送り、v2 P2P プロトコルへの対応を示す。
  • レスポンダーは:
    • パケットを受け取り、その内容を無視する。
  1. アプリケーションフェーズ。ここで交換されるパケットは、アプリケーションデータとして解釈される内容を持つ。
  • いずれかのピアが送信すべきメッセージを持つたびに、そのアプリケーションメッセージを内容として持つパケットを送る。

最初のメッセージが少なくとも 64 バイトであるという認識可能なパターンを避けるため、本プロトコルへの将来の後方互換なアップグレードは、両ピアが公開鍵 + ガーベジ + ガーベジターミネーターを複数ラウンドに分けて送り、進行が保証される限り、それらのバイトを任意にメッセージへ切り分けることを許すかもしれない。後方互換な形で進行をどう保証できるか?進行を保証するには、デッドロックが起きないこと、すなわち各当事者が相手を無期限に待ち続ける状態に至らないことが保証される必要がある。たとえば、以下の条件を満たす任意のアップグレードは進行を保証する。

  • イニシエーターは、マジック/バージョンの 16 バイトプレフィックスと不一致になるために必要な最小限のバイト数以上を送信することから開始しなければならない。
  • レスポンダーは、その 16 バイトプレフィックスと不一致になる少なくとも 1 バイトを受信した後に、送信を開始しなければならない。
  • いずれかの当事者が相手のガーベジターミネーターを受信するか、4095 バイトのガーベジを受信した時点で、自身のガーベジターミネーターを送らなければならない。(これらの条件のいずれかが満たされると、それ以外に相手側は進行を保証するために応答すべきことがなくなる。)
  • いずれかの当事者が、自身のガーベジターミネーターをまだ完全には送っていない間に、0 でない任意の数のバイトを受信した場合、それ以上のバイトを待たずに、少なくとも 1 バイトを応答として送らなければならない。
  • いずれかの当事者が自身のガーベジターミネーターを送り終えた後は、それ以上のバイトを待たずにバージョン交渉フェーズへ移行しなければならない。

ここで規定するプロトコルはこれらの条件を満たしているため、これらの条件も満たす任意のアップグレードは後方互換となる。

バージョン交渉フェーズは鍵交換フェーズの完了を待つ必要がないことに注意されたい。バージョンパケットはガーベジターミネーターを送信した直後に送信できる。したがって、最初の 2 個のフェーズをあわせてハンドシェイクと呼び、これはわずか 1.5 ラウンドトリップで構成される。

  • イニシエーターが公開鍵 + ガーベジを送る
  • レスポンダーが公開鍵 + ガーベジ + ガーベジターミネーター + おとりパケット(任意)+ バージョンパケットを送る
  • イニシエーターがガーベジターミネーター + おとりパケット(任意)+ バージョンパケットを送る

パケット暗号化の概観

ガーベジターミネーターより後の通信路上のすべてのデータは、暗号化パケットの形を取る。各パケットは、内容と呼ばれる暗号化された可変長のバイト配列と、前述の無視ビットを符号化する。パケット全体のサイズは、20 バイトにその内容の長さを加えたものである。

各パケットは以下から構成される。

  • 内容の長さ(0 から 224-1224-1 バイトは最大内容サイズとして十分か?現行のビットコイン P2P プロトコルには、4000000 バイトを超えるアプリケーションペイロードに対応するメッセージは存在しない。224-1 まで対応することで、その値の 4 倍以上を必要とする将来の発展にも対応できる。それを超えるデータ交換を必要とする仮想的なプロトコル変更は、関与するピアごとの受信バッファサイズや、メッセージを完全に受信する前に処理を開始できないことを踏まえ、いずれにせよ複数の別個のメッセージを用いるべきであろう。もちろん、トランスポートプロトコルの将来のバージョンは、本当に必要であれば長さフィールドのサイズを変更することもできる。まで、両端を含む)を符号化する、3 バイトの暗号化された長さフィールド。
  • 以下から構成される平文の認証付き暗号化:
    • トランスポート層プロトコルフラグから構成される 1 バイトのヘッダー。現在は最上位ビットのみが無視ビットとして定義されている。他のビットは無視されるが、これは将来のバージョンで変わりうるなぜヘッダーは長さフィールドと並べてではなく、平文の一部になっているのか?パケット長フィールドは、標準の RFC8439 AEAD を活用する前に利用可能でなければならない最小限の情報である。ヘッダーが内容の暗号化に含まれることを含め、その他のデータ(メタデータなど)は、認証される前に使われるデータに関するプロトコルのセキュリティについて推論することを容易にする。もし無視ビットが内容の一部でなければ、それを認証するための別の機構が必要になる。たとえば AEAD 暗号への AAD として与えることもできるが、我々は、そのようなアプローチの複雑さは、メッセージあたり 1 バイトを節約する利益を上回ると感じている。
    • 可変長の内容

平文の暗号化には、RFC 8439 (https://datatracker.ietf.org/doc/html/rfc8439) で規定される関連データ付き認証暗号 (https://en.wikipedia.org/wiki/Authenticated_encryption)(AEAD)暗号である**ChaCha20Poly1305 (https://en.wikipedia.org/wiki/ChaCha20-Poly1305)**なぜ ChaCha20Poly1305 がパケット暗号化の基盤として選ばれたのか?これは非常に広く使われている認証暗号(SSH、TLS 1.2、TLS 1.3、QUIC (https://en.wikipedia.org/wiki/QUIC)、Noise、WireGuard (https://www.wireguard.com/protocol/) などで使われており、WireGuard では現在唯一対応する暗号でもある)であり、汎用のソフトウェア実装において非常に良好な性能を持つ。AES ベースの暗号(AES ハードウェアアクセラレーションを持たない軽量でないカテゴリーの CAESAR (https://competitions.cr.yp.to/caesar.html) 競技の勝者を含む)は AES ハードウェアアクセラレーションを持つシステムでは著しく高速に動作するが、純粋なソフトウェア実装では著しく遅くもなる。我々は最も弱いハードウェアに合わせて最適化することを選ぶ。を用いる。各パケットの平文は、それぞれ異なるナンスを持つ、別個の AEAD メッセージとして扱われる。

長さは特別に扱われなければならない。パケット全体が受信・認証される前に、パケットの境界を判定するために必要となるためである。受動的攻撃者に対して疑似乱数となるストリームを望むため、これも暗号化を要する。我々はこれに、独立した鍵を用いる、認証なしなぜ長さの暗号化は個別に認証されないのか?非形式的には、我々が目指す関連するセキュリティ目標は、タイミングやフラグメンテーションの情報なしにバイトストリームを受け取る受動的攻撃者に対して、パケットの個数とその長さ(すなわちパケットの境界)を隠すことである。(形式的な定義は、たとえば Hansen 2016(定義 22) (https://himsen.github.io/pdf/thesis.pdf)に「選択平文攻撃に対する境界隠蔽(BH-CPA)」の名で見出せる。)しかし我々は、能動的攻撃者に対してパケットの境界を隠すことは目指さない。能動的攻撃者は、ビットコイン P2P プロトコルが大部分クエリ・レスポンス方式であるという事実を常に悪用できるためである。すなわち、ストリーム上のバイトを 1 バイトずつ、改変せずに小出しにし、応答がいつ来るかを観測できる(詳しい議論は Hansen 2016(3.9 節) (https://himsen.github.io/pdf/thesis.pdf)を参照)。これを踏まえ、我々は、能動的な(MitM ではない)攻撃者が、認証されていない長さフィールドの特定のビットを反転させ、相手側が即座に、または後で切断するかを観測することで、パケット境界に関する何らかの情報を突き止められることを許容する。したがって我々は、受動的攻撃者に対する境界隠蔽を達成するのに十分であり、パケットあたり 16 バイトの帯域幅を節約できる、長さデータに対する認証なしの暗号化を選ぶ。ChaCha20 暗号化を用いる。平文の長さは、平文の暗号化によって暗黙に認証されたままであるが、これはパケット全体を受信した後にしか検証できない点に注意されたい。この設計は、OpenSSH (http://bxr.su/OpenBSD/usr.bin/ssh/PROTOCOL.chacha20poly1305) の ChaCha20Poly1305 暗号スイートの設計に着想を得ている。パケット暗号化は OpenSSH の設計とどう異なるか?相違点は以下のとおりである。

  • 長さフィールドは、我々の目的には 3 バイトで十分であるため、4 バイトではなく 3 バイトである。
  • 長さの暗号化は、パケットごとにナンスを増分するのではなく、複数のパケットにわたって同一の ChaCha20 暗号から疑似乱数バイトを引き続ける。
  • Poly1305 認証タグは暗号化された平文のみを対象とし、暗号化された長さフィールドは対象としない。これは、平文の暗号化が、いかなる変更もなく標準の ChaCha20Poly1305 構成を用いることを意味し、その暗号に関する分析やレビューの適用可能性を最大化する。長さの暗号化は、別個の鍵を用いる別個の層とみなすことができ、したがって平文の暗号化が持つ機密性や完全性の保証には一切影響しない。他方、OpenSSH に対するこの変更は、いかなる性質も悪化させない。誤った長さは、依然としてパケット全体の認証失敗を引き起こすためである(平文の長さは ChaCha20Poly1305 によって暗黙に認証されている)。
  • 前方セキュリティを提供するため、224なぜ再鍵付けの間隔として 224 が選ばれたのか?あるノードが、ある接続で(BIP31 (https://github.com/bitcoin/bips/blob/master/bip-0031.mediawiki) 以降のタイムアウト間隔である)20 分ごとに ping メッセージのみを送るとすると、そのノードは約 3.11 日で 224 個のパケットを送信することになる。これは、固定個数のパケットごとのソフトな再鍵付けが、実際の時間ベースの再鍵付け方式よりもはるかに調整が簡単でありながら、自動的に再鍵付けの時間間隔の上限へと変換されることを意味する。同時に、224 メッセージに 1 回という頻度は十分に低頻度であり、性能への影響は無視できる。さらに、224 かける 3 バイト(長さの暗号化 1 回あたりに消費されるバイト数)は 672 であり、これは 64 の倍数から 32 を引いた値である。これは、224 回の長さ暗号化の終わりに、次の鍵として使えるちょうど 32 バイトのキーストリームデータが残ることを意味する。メッセージごとにハッシュのステップが実行される。 3 バイトの固定長のチャンク(長さフィールド)のみが暗号化されるため、すべての長さチャンクを別個のメッセージとして扱う必要はない。代わりに、複数の連続する長さフィールドに対して(同じナンスを持つ)単一の暗号が用いられる。これにより、パケットあたり 61 疑似乱数バイトの浪費を避け、長さの暗号化のために別個の暗号を持つコストを無視できる水準にする。長さの暗号化に、より標準的でない構成を用いることは許容できるのか?単一の ChaCha20 暗号が生成する複数の(重複しない)バイトが、複数の連続する長さフィールドの暗号化に使われるという事実は、一般的ではない。この逸脱によって得られる性能上の利益は、(ビットコインの P2P プロトコルで非常に一般的な)小さいパケットにおいて特に、その価値があると我々は感じている。長さの暗号化についてそもそも実現可能な保証の水準が低いこと、また、その結果が受動的攻撃者の視点から疑似乱数性を提供するのに依然として十分であることを踏まえてのことである。平文の暗号化については、機密性と完全性にかかる利害がはるかに高いため、我々は独立して非常に標準的な構成を用いる。

前方セキュリティを提供するため前方セキュリティはどのような価値を提供するのか?再鍵付けは、セッション内での前方秘匿性 (https://eprint.iacr.org/2001/035.pdf)を保証する。すなわち、現在のセッション秘密情報を侵害する攻撃者が、同一セッション内の過去の暗号鍵を導出できないことを保証する。なぜ前方秘匿性を持つ暗号でありながら、ECDH 鍵交換の周期的な再実行を行わないのか?我々の暗号は、過去の暗号鍵の下で暗号化されたメッセージを保護するために、暗号鍵をラチェット式に前方へ進める。対照的に、ECDH 鍵交換を再実行すれば、将来の暗号鍵の下で暗号化されるメッセージを保護することになる。すなわち、攻撃者がいずれかのピアを一時的に侵害していた(たとえば攻撃者がメモリーダンプを取得した)後に、セキュリティを再確立することになる。我々は、それに対する保護を優先すべきとは考えていない。何らかの理由でピアの暗号鍵(や他のセッション秘密情報)を暴露する攻撃を一度実行できた攻撃者は、ピア同士が ECDH 鍵交換を再実行した後も、同じ攻撃を再度実行できる可能性が高い。したがって、鍵交換を再実行することの利益が、ピア間で必要となる調整に伴う追加の複雑さを上回るとは考えていない。イニシエーターは、ECDH 鍵交換の更新を強制するために接続全体を閉じて再度開くことを選べる点には留意するが、これは別の問題を引き起こす。レスポンダー側で接続スロットを開いたままにしておく必要があること、本当に同じイニシエーターがそれを使うという暗号学的な保証がないこと、そして観測可能な TCP のリセットとハンドシェイクが検知可能なパターンを作りうることである。、平文と長さの暗号化の両方に用いる暗号鍵は、古い鍵を用いてキーストリームにより生成される新しい鍵に切り替えることで、224 メッセージごとに循環させる。

ハンドシェイク: 鍵交換とバージョン交渉

次に、接続のハンドシェイクを詳細に規定する。

前述のとおり、接続を確立するために以下のメッセージが送られる。


 ----------------------------------------------------------------------------------------------------
 | Initiator                         Responder                                                      |
 |                                                                                                  |
 | x, ellswift_X = ellswift_create()                                                                |
 |                                                                                                  |
 |    ---- ellswift_X + initiator_garbage (initiator_garbage_len bytes; max 4095) --->              |
 |                                                                                                  |
 |                                   y, ellswift_Y = ellswift_create()                              |
 |                                   ecdh_secret = v2_ecdh(                                         |
 |                                                     y, ellswift_X, ellswift_Y, initiating=False) |
 |                                   initialize_v2_transport(                                       |
 |                                       initiator, ecdh_secret, initiating=False)                  |
 |                                                                                                  |
 |    <--- ellswift_Y + responder_garbage (responder_garbage_len bytes; max 4095) +                 |
 |             responder_garbage_terminator (16 bytes) +                                            |
 |             v2_enc_packet(initiator, RESPONDER_TRANSPORT_VERSION, aad=responder_garbage) ----    |
 |                                                                                                  |
 | ecdh_secret = v2_ecdh(x, ellswift_Y, ellswift_X, initiating=True)                                |
 | initialize_v2_transport(responder, ecdh_secret, initiating=True)                                 |
 |                                                                                                  |
 |     ---- initiator_garbage_terminator (16 bytes) +                                               |
 |              v2_enc_packet(responder, INITIATOR_TRANSPORT_VERSION, aad=initiator_garbage) --->   |
 |                                                                                                  |
 ----------------------------------------------------------------------------------------------------
共有秘密の計算

ピアは、通信路上で送られた正確に 64 バイトの公開鍵符号化とともにハッシュされた、X-only ECDH を通じて共有秘密を導出する。


def v2_ecdh(priv, ellswift_theirs, ellswift_ours, initiating):
    ecdh_point_x32 = ellswift_ecdh_xonly(ellswift_theirs, priv)
    if initiating:
        # Initiating, place our public key encoding first.
        return sha256_tagged("bip324_ellswift_xonly_ecdh", ellswift_ours + ellswift_theirs + ecdh_point_x32)
    else:
        # Responding, place their public key encoding first.
        return sha256_tagged("bip324_ellswift_xonly_ecdh", ellswift_theirs + ellswift_ours + ecdh_point_x32)

ここで sha256_tagged(tag, x) は、BIP340 (https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki#specification) と同様に、タグ付きハッシュ値 SHA256(SHA256(tag) || SHA256(tag) || x) を返す。

曲線 X 座標の ElligatorSwift 符号化

ellswift_create 関数と ellswift_ecdh_xonly 関数は、ElligatorSwift 符号化された公開鍵の構築と、ElligatorSwift 符号化された公開鍵を用いた X-only ECDH の計算をカプセル化する。

まず定数を定義する。

  • c = 0xa2d2ba93507f1df233770c2a797962cc61f6d15da14ecd47d8d27ae1cd5f852 とする。XSwiftEC で使われる定数 c とは何か?このアルゴリズムは、定数 √-3 (mod p)、言い換えれば -c2 mod p = 3 を満たす数 c を必要とする。この方程式には 2 つの解があり、一方はそれ自体が法 p での平方剰余であり、もう一方はその負である。我々は平方剰余である方を選ぶ。

必要な関数を定義するため、まず、SwiftEC (https://eprint.iacr.org/2022/759.pdf) 論文の XSwiftEC 関数を secp256k1 曲線向けに具体化し、軽微な修正を加えた補助関数を導入する。これは整数の組 (u, t)(いずれも範囲 0..p-1)を、曲線上の有効な X 座標へ写像する。ここでの規定は秘密データを扱わないため、定数時間であることを意図していない点に注意されたい。以降では BIP340 (https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki#specification) の記法を用いる。

  • XSwiftEC(u, t):
    • 曲線上の X 座標であることを保証するため、入力を変える:なぜ XSwiftEC アルゴリズムへの入力を変える必要があるのか?この手順は、無限遠点へ無視できるほど小さい部分集合(およそ 3/2256)を写像する論文から逸脱するものである。ピアがこの端的なケースを意図的に引き起こす符号化を作れてしまう場合に対処する必要を避けるため、我々はそれらを有効な X 座標を生む入力へ再写像する。
      • u mod p = 0 の場合、代わりに u = 1 とする。
      • t mod p = 0 の場合、代わりに t = 1 とする。
      • (u3 + t2 + 7) mod p = 0 の場合、代わりに t = 2t (mod p) とする。
    • X = (u3 + 7 - t2)/(2t) (mod p) とする。モジュラー演算における除算(/)記号は何を指すのか?これらの式における除算は、法 p における乗法逆元との乗算に対応する。すなわち、非零の b に対する a / b (mod p) とは、bx = a (mod p) を満たす一意の解 x である。これは abp-2 (mod p) として計算できるが、より効率的なアルゴリズムも存在する。
    • Y = (X + t)/(cu) (mod p) とする。
    • {u + 4Y2, (-X/Y - u)/2, (X/Y - u)/2}(いずれも mod p。順序が重要)内の各 x について:
      • lift_x(x) が成功すれば、その x を返す。少なくとも 1 個のそのような x が存在する。

与えられた X 座標 x の符号化を見つけるには、まず XSwiftEC の逆関数が必要である。関数 XSwiftECInv(x, u, case) は、XSwiftEC(u, t) = x を満たす t、または None を返す。case 変数は範囲 0..7 の整数であり、最大 8 個の有効な t 値のうちどれを返すかを選ぶ。

  • XSwiftECInv(x, u, case):
    • case & 2 = 0 の場合:
      • lift_x(-x - u) が成功すれば、None を返す。
      • v = x とする。
      • s = -(u3 + 7)/(u2 + uv + v2) (mod p) とする。
    • それ以外(case & 2 = 2)の場合:
      • s = x - u (mod p) とする。
      • s = 0 の場合、None を返す。
      • r-s(4(u3 + 7) + 3u2s) (mod p) の平方根とする。p における平方根はどう計算するか? p の構造により、法 p における a の平方根の候補は x = a(p+1)/4 mod p として計算できる。a が法 p で平方剰余でない場合、この式は代わりに -a mod p の平方根を返すため、x2 mod p = a であることを検証する必要がある。その場合、-x mod p も解であるが、我々は「その」平方根をこの式に等しいものとして定義する(したがって平方根は常にそれ自体が平方剰余となる。(p+1)/4 は偶数であるため)。このアルゴリズムは Tonelli-Shanks アルゴリズム (https://en.wikipedia.org/wiki/Tonelli%E2%80%93Shanks_algorithm)の特殊化である。存在しなければ None を返す。
      • case & 1 = 1 かつ r = 0 の場合、None を返す。
      • v = (r/s - u)/2 とする。
    • ws (mod p) の平方根とする。存在しなければ None を返す。
    • case & 5 = 0 の場合、-w(u(1 - c)/2 + v) を返す。
    • case & 5 = 1 の場合、w(u(1 + c)/2 + v) を返す。
    • case & 5 = 4 の場合、w(u(1 - c)/2 + v) を返す。
    • case & 5 = 5 の場合、-w(u(1 + c)/2 + v) を返す。

論文で使われる名称に合わせた全体の XElligatorSwift アルゴリズムは、この逆関数を用いて、x の符号化をランダムにElligatorSwift 符号化を用いて、(疑似乱数ではなく)ある構造を満たす公開鍵符号化を構築できるか?このアルゴリズムは、最初の 32 バイト(すなわち値 u)を選び、曲線上の点への写像が成り立つよう対応する t を計算する。一般に、u を一様乱数分布から選べば疑似乱数性が得られる。しかし u の 32 バイトのいずれかを固定しても、アルゴリズムは対応する t を見つけられる。最初の 32 バイトを固定できるという事実は、ハンドシェイク内のガーベジバイトと組み合わさって、TLS 1.3 (https://tls13.xargs.org/) のような他のプロトコルを模倣する、限定的ではあるが非常に単純な手法を提供する。これは、相手側からの明示的な対応なしに、いずれか一方のピアによって展開できる。新しいプロトコルやプロトコルアップグレードの定義によって導入されるような、より一般的な模倣の手法も排除されない。サンプリングする。

  • XElligatorSwift(x):
    • ループ:
      • u を範囲 1..p-1(両端含む)のランダムな非零整数とする。
      • case を範囲 0..7(両端含む)のランダムな整数とする。
      • t = XSwiftECInv(x, u, case) を計算する。
      • tNone でなければ、(u, t) を返す。そうでなければループを再開する。

これは、前節で使われる ellswift_create アルゴリズムを定義するために用いられる。これはランダムな秘密鍵と、それに対応する一様にサンプリングされた 64 バイトの ElligatorSwift 符号化公開鍵を生成する。

  • ellswift_create():
    • 範囲 1..p-1 のランダムな秘密鍵 priv を生成する。
    • P = priv⋅G を、priv に対応する公開鍵の点とする。
    • (u, t) = XElligatorSwift(x(P)) を、x(P) の符号化とする。
    • ellswift_pub = bytes(u) || bytes(t) を、その 64 バイトとしての符号化とする。
    • (priv, ellswift_pub) を返す。

最後に ellswift_ecdh_xonly アルゴリズムは以下のとおりである。

  • ellswift_ecdh_xonly(ellswift_theirs, priv):
    • u = int(ellswift_theirs[:32]) mod p とする。
    • t = int(ellswift_theirs[32:]) mod p とする。
    • bytes(x(priv⋅lift_x(XSwiftEC(u, t)))) を返す。lift_x がどちらの点に写像するかは重要か?いずれの点も有効である。それらは互いの負であり、負を取っても出力される X 座標には影響しないためである。
鍵とセッション ID の導出

ここで提案する認証暗号の構成は、通信方向ごとに 2 個の 32 バイトの鍵を必要とする。これら(およびセッション ID)は、SHA256 をハッシュ関数として用いる RFC 5869 (https://tools.ietf.org/html/rfc5869) で規定される HKDFなぜ鍵素材の導出に HKDF を用いるのか?共有秘密にはすでに、公開鍵符号化がそれに寄与することを保証するためのハッシュ関数が関わっており、これはすでに HKDF の必要性の一部を打ち消している。それでも、単一の秘密から多数の鍵を導出するための標準的な機構であり、その計算コストが接続確立の残りの部分と比べて無視できるほど低いことから、我々は HKDF を用いる。を用いて計算される。


def initialize_v2_transport(peer, ecdh_secret, initiating):
    # Include NETWORK_MAGIC to ensure a connection between nodes on different networks will immediately fail
    prk = HKDF_Extract(Hash=sha256, salt=b'bitcoin_v2_shared_secret' + NETWORK_MAGIC, ikm=ecdh_secret)

    peer.session_id = HKDF_Expand(Hash=sha256, PRK=prk, info=b'session_id', L=32)

    # Initialize the packet encryption ciphers.
    initiator_L = HKDF_Expand(Hash=sha256, PRK=prk, info=b'initiator_L', L=32)
    initiator_P = HKDF_Expand(Hash=sha256, PRK=prk, info=b'initiator_P', L=32)
    responder_L = HKDF_Expand(Hash=sha256, PRK=prk, info=b'responder_L', L=32)
    responder_P = HKDF_Expand(Hash=sha256, PRK=prk, info=b'responder_P', L=32)
    garbage_terminators = HKDF_Expand(Hash=sha256, PRK=prk, info=b'garbage_terminators', L=32)
    initiator_garbage_terminator = garbage_terminators[:16]
    responder_garbage_terminator = garbage_terminators[16:]

    if initiating:
        peer.send_L = FSChaCha20(initiator_L)
        peer.send_P = FSChaCha20Poly1305(initiator_P)
        peer.send_garbage_terminator = initiator_garbage_terminator
        peer.recv_L = FSChaCha20(responder_L)
        peer.recv_P = FSChaCha20Poly1305(responder_P)
        peer.recv_garbage_terminator = responder_garbage_terminator
    else:
        peer.send_L = FSChaCha20(responder_L)
        peer.send_P = FSChaCha20Poly1305(responder_P)
        peer.send_garbage_terminator = responder_garbage_terminator
        peer.recv_L = FSChaCha20(initiator_L)
        peer.recv_P = FSChaCha20Poly1305(initiator_P)
        peer.recv_garbage_terminator = initiator_garbage_terminator

    # To achieve forward secrecy we must wipe the key material used to initialize the ciphers:
    memory_cleanse(ecdh_secret, prk, initiator_L, initiator_P, responder_L, responder_K)

セッション ID は暗号化されたチャネルを一意に識別する。本提案に対応する v2 クライアントは、ノード運用者同士による手動の帯域外比較を可能にするため、セッション ID 全体を(16 進文字列として符号化して)ノード運用者に提示してよい。将来のトランスポートバージョンでは、暗号化されたチャネルを認証に結び付けるため、両端点それぞれが見るセッション ID を比較する任意の認証手法が導入されうる。

ハンドシェイク全体の擬似コード

v2 の暗号化接続を確立するため、イニシエーターは一時的な secp256k1 鍵ペアを生成し、公開鍵の非暗号化 ElligatorSwift 符号化を、それに続けて garbage_len < 4096 の長さを持つ非暗号化の疑似乱数バイト initiator_garbage を、レスポンダーへ送る。


def initiate_v2_handshake(peer, garbage_len):
    peer.privkey_ours, peer.ellswift_ours = ellswift_create()
    peer.sent_garbage = rand_bytes(garbage_len)
    send(peer, peer.ellswift_ours + peer.sent_garbage)

レスポンダーは自身のために一時鍵ペアを生成し、(最初に受信した 64 バイトを用いて)共有 ECDH 秘密を導出し、これにより暗号化トランスポートをインスタンス化できるようになる。次に、自身の公開鍵の非暗号化 ElligatorSwift 符号化 64 バイトと、同じく garbage_len < 4096 の長さを持つ自身の responder_garbage を送る。受信した先頭 16 バイトが v1 プレフィックスと一致する場合、代わりに v1 プロトコルが用いられる。


TRANSPORT_VERSION = b''
NETWORK_MAGIC = b'\xf9\xbe\xb4\xd9' # Mainnet network magic; differs on other networks.
V1_PREFIX = NETWORK_MAGIC + b'version\x00\x00\x00\x00\x00'

def respond_v2_handshake(peer, garbage_len):
    peer.received_prefix = b""
    while len(peer.received_prefix) < len(V1_PREFIX):
        peer.received_prefix += receive(peer, 1)
        if peer.received_prefix[-1] != V1_PREFIX[len(peer.received_prefix) - 1]:
            peer.privkey_ours, peer.ellswift_ours = ellswift_create()
            peer.sent_garbage = rand_bytes(garbage_len)
            send(peer, ellswift_Y + peer.sent_garbage)
            return
    use_v1_protocol()

レスポンダーの符号化された公開鍵を受信すると、イニシエーターは共有 ECDH 秘密を導出し、暗号化トランスポートをインスタンス化する。次に、導出された 16 バイトの initiator_garbage_terminator を送り、任意で、それに続けて任意個数のおとりパケットを送る。その後、(ガーベジターミネーターで区切られた)レスポンダーのガーベジを受信する。レスポンダーも非常によく似た手順を行うが、先に受信したプレフィックスバイトを公開鍵に含める点が異なる。イニシエーターとレスポンダーの双方は、ガーベジターミネーターの後に送る最初の暗号化パケット(すなわち任意のおとりパケットまたはバージョンパケットのいずれか)の AAD を、ガーベジターミネーターを含まない、自身が送ったばかりのガーベジに設定する。


def complete_handshake(peer, initiating, decoy_content_lengths=[]):
    received_prefix = b'' if initiating else peer.received_prefix
    ellswift_theirs = receive(peer, 64 - len(received_prefix))
    if not initiating and ellswift_theirs[4:16] == V1_PREFIX[4:16]:
        # Looks like a v1 peer from the wrong network.
        disconnect(peer)
    ecdh_secret = v2_ecdh(peer.privkey_ours, ellswift_theirs, peer.ellswift_ours,
                          initiating=initiating)
    initialize_v2_transport(peer, ecdh_secret, initiating=True)
    # Send garbage terminator
    send(peer, peer.send_garbage_terminator)
    # Optionally send decoy packets after garbage terminator.
    aad = peer.sent_garbage
    for decoy_content_len in decoy_content_lengths:
        send(v2_enc_packet(peer, decoy_content_len * b'\x00', aad=aad))
        aad = b''
    # Send version packet.
    send(v2_enc_packet(peer, TRANSPORT_VERSION, aad=aad))
    # Skip garbage, until encountering garbage terminator.
    received_garbage = recv(peer, 16)
    for i in range(4096):
        if received_garbage[-16:] == peer.recv_garbage_terminator:
            # Receive, decode, and ignore version packet.
            # This includes skipping decoys and authenticating the received garbage.
            v2_receive_packet(peer, aad=received_garbage[:-16])
            return
        else:
            received_garbage += recv(peer, 1)
    # Garbage terminator was not seen after 4 KiB of garbage.
    disconnect(peer)

パケット暗号化

最後に、パケット暗号化の暗号方式を詳細に規定する。

既存の暗号プリミティブ

パケット暗号化は、2 個の既存プリミティブの上に構築される。

  • ChaCha20Poly1305RFC 8439 の 2.8 節 (https://datatracker.ietf.org/doc/html/rfc8439#section-2.8)AEAD_CHACHA20_POLY1305 として規定されている。これは、256 ビットの鍵、96 ビットのナンス、および任意長のバイト配列である関連付けられた認証データ(AAD)を取る、関連データ付き認証暗号(AEAD)である。組み込みの認証タグにより、暗号文は対応する平文より 16 バイト長くなる。以降では:
    • aead_chacha20_poly1305_encrypt(key, nonce, aad, plaintext) は、32 バイト配列 key、12 バイト配列 ナンス、任意長バイト配列 aad、任意長バイト配列 plaintext を入力に取り、平文より 16 バイト長いバイト配列 ciphertext を返す関数を指す。
    • aead_chacha20_poly1305_decrypt(key, nonce, aad, ciphertext) は、32 バイト配列 key、12 バイト配列 ナンス、任意長バイト配列 aad、任意長バイト配列 ciphertext を入力に取り、バイト配列 plaintext(暗号文より 16 バイト短い)を返すか、指定した keyナンスaad によるいかなる平文の有効な ChaCha20Poly1305 暗号文でもなかった場合には None を返す関数を指す。
  • ChaCha20 ブロック関数RFC 8439 の 2.3 節 (https://datatracker.ietf.org/doc/html/rfc8439#section-2.3)で規定されている。これは 256 ビットの鍵、96 ビットのナンス、32 ビットのカウンターを取り、64 個の疑似乱数バイトを出力する疑似乱数関数(PRF)である。これは ChaCha20(そして最終的には ChaCha20Poly1305)が構築される基盤のブロックである。以降では:
    • chacha20_block(key, nonce, count) は、32 バイト配列 key、12 バイト配列 ナンス、範囲 0..232-1 の整数 count を入力に取り、長さ 64 のバイト配列を返す関数を指す。

これらはそれぞれ、平文の暗号化と長さの暗号化に用いられる。

再鍵付けラッパー: FSChaCha20Poly1305 と FSChaCha20

224 パケットごとの再鍵付けを提供するため、2 個のラッパーを規定する。

1 個目は FSChaCha20Poly1305 であり、これは ChaCha20Poly1305 AEAD を表し、メッセージごとに自動的にナンスを変更し、224 メッセージごとに 32 個のゼロバイトを暗号化してなぜ再鍵付けは AEAD の呼び出しという形で実装されているのか?これは、FSChaCha20Poly1305 ラッパーを、ChaCha20Poly1305 AEAD の純粋なラッパー層とみなせることを意味する。実際の実装は、この定式化が、基盤となる ChaCha20 暗号のキーストリーム出力のバイト 64 から 95 を新しい鍵として用いることと等価であるという事実を活用でき、その過程で Poly1305 を必要としない。、その結果の先頭 32 バイトを用いることで再鍵付けを行う。各メッセージは 1 個のパケットに用いられる。我々のプロトコルでは、いかなる FSChaCha20Poly1305 インスタンスも常に、暗号化専用または復号専用のいずれかである点に注意されたい。プロトコルの各方向に別個のインスタンスが用いられるためである。あるメッセージに使われるナンスは、現在の鍵で送られたメッセージ数の 32 ビットリトルエンディアン符号化に、実行された再鍵付けの回数の 64 ビットリトルエンディアン符号化を続けたものから構成される。再鍵付けの際は、最初の 32 ビット整数は 0xffffffff に設定される。


REKEY_INTERVAL = 224

class FSChaCha20Poly1305:
    """Rekeying wrapper AEAD around ChaCha20Poly1305."""

    def __init__(self, initial_key):
        self.key = initial_key
        self.packet_counter = 0

    def crypt(self, aad, text, is_decrypt):
        nonce = ((self.packet_counter % REKEY_INTERVAL).to_bytes(4, 'little') +
                 (self.packet_counter // REKEY_INTERVAL).to_bytes(8, 'little'))
        if is_decrypt:
            ret = aead_chacha20_poly1305_decrypt(self.key, nonce, aad, text)
        else:
            ret = aead_chacha20_poly1305_encrypt(self.key, nonce, aad, text)
        if (self.packet_counter + 1) % REKEY_INTERVAL == 0:
            rekey_nonce = b"\xFF\xFF\xFF\xFF" + nonce[4:]
            self.key = aead_chacha20_poly1305_encrypt(self.key, rekey_nonce, b"", b"\x00" * 32)[:32]
        self.packet_counter += 1
        return ret

    def decrypt(self, aad, ciphertext):
        return self.crypt(aad, ciphertext, True)

    def encrypt(self, aad, plaintext):
        return self.crypt(aad, plaintext, False)

2 個目は FSChaCha20 であり、これはすべてのパケットの長さに用いられる(単一の)ストリーム暗号である。ここでは暗号化と復号が同一であるため、単一の関数 crypt が公開される。これは、ChaCha20 ブロック関数を用いて生成されるバイトと入力を XOR し、ブロック関数出力の次の 32 バイトを新しい鍵として用いることで、224 チャンクごとに再鍵付けを行う。ここでのチャンクとは、crypt の単一の呼び出しを指す。前述のとおり、暗号出力の浪費を避けるため、同じ暗号が 224 個の連続するチャンクに用いられる。この 224 チャンクのバッチに用いられるナンスは、4 個のゼロバイトに、実行された再鍵付けの回数の 64 ビットリトルエンディアン符号化を続けたものから構成される。ブロックカウンターは、再鍵付けのたびに 0 にリセットされる。


class FSChaCha20:
    """Rekeying wrapper stream cipher around ChaCha20."""

    def __init__(self, initial_key):
        self.key = initial_key
        self.block_counter = 0
        self.chunk_counter = 0
        self.keystream = b''

    def get_keystream_bytes(self, nbytes):
        while len(self.keystream) < nbytes:
            nonce = ((0).to_bytes(4, 'little') +
                     (self.chunk_counter // REKEY_INTERVAL).to_bytes(8, 'little'))
            self.keystream += chacha20_block(self.key, nonce, self.block_counter)
            self.block_counter += 1
        ret = self.keystream[:nbytes]
        self.keystream = self.keystream[nbytes:]
        return ret

    def crypt(self, chunk):
        ks = self.get_keystream_bytes(len(chunk))
        ret = bytes([ks[i] ^ chunk[i] for i in range(len(chunk))])
        if ((self.chunk_counter + 1) % REKEY_INTERVAL) == 0:
            self.key = self.get_keystream_bytes(32)
            self.block_counter = 0
        self.chunk_counter += 1
        return ret
パケット暗号化・復号全体の擬似コード

パケットの暗号化と復号は、前節の暗号を構成要素として組み合わせることで実現される。


LENGTH_FIELD_LEN = 3
HEADER_LEN = 1
IGNORE_BIT_POS = 7

def v2_enc_packet(peer, contents, aad=b'', ignore=False):
    assert len(contents) <= 2**24 - 1
    header = (ignore << IGNORE_BIT_POS).to_bytes(HEADER_LEN, 'little')
    plaintext = header + contents
    aead_ciphertext = peer.send_P.encrypt(aad, plaintext)
    enc_contents_len = peer.send_L.encrypt(len(contents).to_bytes(LENGTH_FIELD_LEN, 'little'))
    return enc_contents_len + aead_ciphertext

CHACHA20POLY1305_EXPANSION = 16

def v2_receive_packet(peer, aad=b''):
    while True:
        enc_contents_len = receive(peer, LENGTH_FIELD_LEN)
        contents_len = int.from_bytes(peer.recv_L.crypt(enc_contents_len), 'little')
        aead_ciphertext = receive(peer, HEADER_LEN + contents_len + CHACHA20POLY1305_EXPANSION)
        plaintext = peer.recv_P.decrypt(aad, aead_ciphertext)
        if plaintext is None:
            disconnect(peer)
            break
        # Only the first packet is expected to have non-empty AAD.
        aad = b''
        header = plaintext[:HEADER_LEN]
        if not (header[0] & (1 << IGNORE_BIT_POS)):
            return plaintext[HEADER_LEN:]

性能

各 v1 P2P メッセージは、4 バイトに切り詰められた二重 SHA256 チェックサムを用いる。本提案の v2 P2P メッセージを暗号化・認証するために必要な計算能力は、おおむね同程度である。

アプリケーション層仕様

v2 ビットコイン P2P メッセージ構造

v2 ビットコイン P2P トランスポート層のパケットは、上述の暗号化されたメッセージ構造を用いる。暗号化されていないアプリケーション層の内容は、以下から構成される。

フィールドサイズ(バイト)備考
message_type1 または 13範囲 1..255 の 1 バイト ID、または(v1 P2P プロトコルと同様の)12 バイトの ASCII メッセージ型が続く b'\x00' のいずれか
message_payloadmessage_lengthメッセージペイロード

message_type の先頭バイトが b'\x00' である場合、続く 12 バイトは(v1 P2P プロトコルと同様に)ASCII メッセージ型として解釈され、必要に応じて末尾が b'\x00' でパディングされる。message_type の先頭バイトが範囲 1..255 にある場合、それはメッセージ型 ID として解釈される。この構造により、送受信される大半のメッセージがメッセージ型 ID を持つことになるため、v1 プロトコルよりも小さいメッセージが得られる。接続の各方向で 1 回を超えて送られるメッセージ型には、1 バイトの型 ID を予約することを推奨する。v1 と v2 の間で長さはどう比較されるか? 1 バイトの短いメッセージ型 ID を用いるメッセージについて、v2 パケットは v1 よりもメッセージあたり 3 バイト少ない。なぜ可変長の長いメッセージ型 ID を許容しないのか?可変長の長い ID を許容すると、(長さ自体を符号化するために)利用可能な 1 バイト ID の空間が 12 減り、説明性の低いメッセージ型を助長することになる。加えて、メッセージ型を 1 または 13 の固定長に限定することは、トラフィック分析を妨げる。

message_length の値は、length から message_type のサイズを引いたものである。

以下の表は、現在定義されているメッセージ型 ID と、それらが等価に扱われる(末尾のパディングを取り除いた)12 バイトの ASCII メッセージ型を列挙する。

0123
+0(12 bytes follow)addrblockblocktxn
+4cmpctblockfeefilterfilteraddfilterclear
+8filterloadgetblocksgetblocktxngetdata
+12getheadersheadersinvmempool
+16merkleblocknotfoundpingpong
+20sendcmpcttxgetcfilterscfilter
+24getcfheaderscfheadersgetcfcheckptcfcheckpt
+28addrv2
≥29(undefined)

あるメッセージ型が 1 バイトの符号化と 13 バイトの符号化の両方を持つ場合、そのメッセージ型の受信に対応するピアは、いずれの符号化を用いたメッセージも受け入れるべきである(たとえば “getblocktxn” メッセージ型に対応している場合、1 バイトの b'\x0a' 符号化と 13 バイトの b'\x00getblocktxn\x00' 符号化の両方に対応すべきであり、いずれの符号化を受信したかによって挙動が変わってはならない)。

追加のメッセージ型 ID は他の BIP によって定義されてよい。それらは調整を容易にするため、メッセージ型 ID 表に追加されるべきである。

シグナリング仕様

v2 対応のシグナリング

v2 トランスポートプロトコルに対応するピアは、addr リレーにおいて NODE_P2P_V2 = (1 << 11) サービスフラグを告知することで対応を示す。v2 接続の確立時に即座に切断された場合、本提案を実装するクライアントは v1 プロトコルでの再接続を試みることが推奨される。即座に切断された v2 クライアントは、なぜ v1 接続での再試行を推奨されるのか?サービスフラグは、ADDR および ADDRV2 P2P メッセージを用いて信頼できない仲介者を通じて伝播し、複数の情報源から受信した場合は OR 演算される。信頼できない仲介者が、あるピアを実際には対応していないのに v2 接続に対応しているものとして偽って告知する可能性がある。v1 への接続のダウングレードは、あるネットワーク参加者が偽の告知によってブラックホール化されるリスクを緩和する。

テストベクトル

開発・テストの目的のため、CSV 形式のテストベクトル一式と、関連アルゴリズムの、素朴で著しく非効率なリファレンス実装を提供する。このコードはあくまで実演のためのものである。

  • XElligatorSwift 復号ベクトルは、ElligatorSwift 符号化された公開鍵と、それらが写像する X 座標の例を提供する。
  • XSwiftECInv ベクトルは、(u, x) の組と、xswiftec_inv がそれらを写像する各種の t 値の例を提供する。
  • パケット符号化ベクトルは、本文書が提案する認証暗号方式のライフサイクルを例示する。

変更履歴

  • 1.0.2 (2026-01-30)
    • 補助ファイルにメッセージ型 ID 表を追加
  • 1.0.1 (2026-01-16)
    • 1 バイトと 13 バイトの message_type の等価性を規定
  • 1.0.0 (2024-07-10)
    • Final と明記

根拠と参考文献

謝辞

本提案のアイデアを考案・発展させる助けとなった、すべての方々(姓のアルファベット順)に感謝する。

  • Matt Corallo
  • Lloyd Fournier
  • Gregory Maxwell
  • Anthony Towns