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

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バイトのターミネーターは、そのような衝突の確率を無視できる水準に抑え、それはインターネット上のランダムな接続障害よりも桁違いに低い可能性が高い。実際のガーベジターミネーターはどのようなものか? 郵便受けかごみ圧縮機に似たロボットのイラスト。胸にはビットコインのロゴが光り、片方の機械の腕をチェックマークの形に上げて、開いた収納箱の山の横で、三角コーンに乗っている。 (各方向に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とする。
    • wをs (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) を計算する。
      • tがNoneでなければ、(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個の既存プリミティブの上に構築される。

  • ChaCha20Poly1305はRFC 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と明記

根拠と参考文献

謝辞

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

  • マット・コラロ
  • Lloyd Fournier
  • グレゴリー・マクスウェル
  • アンソニー・タウンズ