Goブログ

完全に再現可能な、検証済みのGoツールチェーン

Russ Cox
2023年8月28日

オープンソースソフトウェアの重要な利点の1つは、誰でもソースコードを読み、それが何をしているかを検査できることです。しかし、ほとんどのソフトウェア、オープンソースソフトウェアでさえ、コンパイル済みバイナリとしてダウンロードされます。コンパイル済みバイナリは、検査がはるかに困難です。攻撃者がオープンソースプロジェクトに対してサプライチェーン攻撃を実行しようとする場合、ソースコードを変更せずに提供されているバイナリを置き換えることが最も目立たない方法です。

この種の攻撃に対処する最良の方法は、オープンソースソフトウェアのビルドを再現可能にすることです。つまり、同じソースから開始するビルドは、実行するたびに同じ出力を生成することを意味します。そうすれば、誰でも本物のソースからビルドし、再構築されたバイナリが投稿されたバイナリとビット単位で同一であることを確認することで、投稿されたバイナリに隠れた変更がないことを検証できます。このアプローチは、バイナリを逆アセンブルしたり、内部を調べる必要なしに、バイナリにバックドアやソースコードに存在しない他の変更がないことを証明します。誰でもバイナリを検証できるため、独立したグループがサプライチェーン攻撃を容易に検出し、報告できます。

サプライチェーンのセキュリティが重要になるにつれて、再現可能なビルドも重要になります。なぜなら、それらはオープンソースプロジェクトの投稿されたバイナリを検証する簡単な方法を提供するからです。

Go 1.21.0は、完全に再現可能なビルドを持つ最初のGoツールチェーンです。以前のツールチェーンは再現可能でしたが、かなりの労力が必要であり、おそらく誰も実行しませんでした。彼らは単にgo.dev/dlに投稿されたバイナリが正しいものだと信頼していました。今では「信頼するが、検証する」ことが容易になりました。

この投稿では、ビルドを再現可能にするために必要なもの、Goツールチェーンを再現可能にするためにGoに加えなければならなかった多くの変更について説明し、Go 1.21.0のUbuntuパッケージを検証することで、再現性の利点の1つを示します。

ビルドを再現可能にする

コンピューターは一般的に決定論的であるため、すべてのビルドが同様に再現可能になると考えるかもしれません。それはある視点からのみ真実です。ビルドの出力がその入力によって変化する可能性がある場合、情報を関連入力と呼びましょう。ビルドは、すべて同じ関連入力で繰り返すことができる場合、再現可能です。残念ながら、多くのビルドツールは、通常は関連していると気づかない、そして再現したり入力として提供したりするのが困難な可能性のある入力を組み込むことが判明しています。関連していることが判明したが、意図したものではない入力を意図しない入力と呼びましょう。

ビルドシステムで最も一般的な意図しない入力は、現在時刻です。ビルドが実行可能ファイルをディスクに書き込む場合、ファイルシステムは現在時刻を実行可能ファイルの変更時刻として記録します。「tar」や「zip」のようなツールを使用してそのファイルをパッケージ化すると、変更時刻がアーカイブに書き込まれます。ビルドを現在時刻に基づいて変更したくはありませんでしたが、変更されます。そのため、現在時刻はビルドへの意図しない入力になります。さらに悪いことに、ほとんどのプログラムでは現在時刻を入力として提供できないため、このビルドを繰り返す方法がありません。これを修正するには、作成されたファイルのタイムスタンプをUnix時間0またはビルドのソースファイルの1つから読み取った特定の時刻に設定することができます。そうすれば、現在時刻はビルドへの関連入力ではなくなります。

ビルドへの一般的な関連入力には、以下が含まれます。

  • ビルドするソースコードの特定のバージョン。
  • ビルドに含まれる依存関係の特定のバージョン。
  • ビルドを実行しているオペレーティングシステム。結果のバイナリ内のパス名に影響を与える可能性があります。
  • ビルドシステムのCPUのアーキテクチャ。コンパイラが使用する最適化や特定のデータ構造のレイアウトに影響を与える可能性があります。
  • 使用されているコンパイラバージョンと、それに渡されるコンパイラオプション。コードのコンパイル方法に影響を与えます。
  • ソースコードを含むディレクトリの名前。デバッグ情報に表示される可能性があります。
  • ビルドを実行しているアカウントのユーザー名、グループ名、uid、およびgid。アーカイブ内のファイルメタデータに表示される可能性があります。
  • その他多数。

再現可能なビルドにするには、すべての関連入力をビルドで構成可能にし、すべての関連入力をリストした明示的な構成とともにバイナリを投稿する必要があります。それを行った場合、再現可能なビルドができました。おめでとうございます!

しかし、まだ終わりではありません。適切なアーキテクチャのコンピューターを見つけて、特定のオペレーティングシステムバージョン、コンパイラバージョンをインストールし、ソースコードを正しいディレクトリに配置し、ユーザーIDを正しく設定するなど、最初に多くの作業を行う場合にのみバイナリを再現できる場合、実際には誰も気にしないかもしれません。

ビルドを再現可能にするだけでなく、簡単に再現可能にする必要があります。そのためには、関連入力を特定し、ドキュメント化する代わりに、それらを削除する必要があります。ビルドは明らかにビルドされるソースコードに依存する必要がありますが、それ以外はすべて削除できます。ビルドの唯一の関連入力がビルドされるソースコードである場合、それを完全に再現可能と呼びましょう。

Goの完全に再現可能なビルド

Go 1.21以降、Goツールチェーンは完全に再現可能になりました。その唯一の関連入力はそのビルドのソースコードです。特定のツールチェーン(たとえば、Linux/x86-64用Go)をLinux/x86-64ホスト、Windows/ARM64ホスト、FreeBSD/386ホスト、またはGoをサポートするその他のホストでビルドでき、Go 1.4のC実装までさかのぼるブートストラップコンパイラを使用でき、他の詳細も変更できます。それらのいずれも、ビルドされるツールチェーンを変更しません。同じツールチェーンソースコードから始めると、まったく同じツールチェーンバイナリが生成されます。

この完全な再現性は、元々はGo 1.10にさかのぼる取り組みの集大成ですが、ほとんどの取り組みはGo 1.20とGo 1.21に集中していました。このセクションでは、削除した最も興味深い関連入力の一部を強調します。

Go 1.10での再現性

Go 1.10では、ファイルの変更時刻ではなく、ビルド入力のフィンガープリントに基づいてターゲットが最新かどうかを決定するコンテンツ認識ビルドキャッシュが導入されました。ツールチェーン自体はそのビルド入力の1つであり、GoはGoで記述されているため、ブートストラッププロセスは、単一のマシンでのツールチェーンビルドが再現可能な場合にのみ収束します。全体的なツールチェーンビルドは次のようになります。

まず、以前のバージョンのGo(ブートストラップツールチェーン(Go 1.10はCで記述されたGo 1.4を使用、Go 1.21はGo 1.17を使用))を使用して、現在のGoツールチェーンのソースをビルドします。「toolchain1」が生成され、それを再度すべてビルドするために使用して「toolchain2」が生成され、それを再度すべてビルドするために使用して「toolchain3」が生成されます。

Toolchain1とtoolchain2は同じソースからビルドされましたが、異なるGo実装(コンパイラとライブラリ)を使用しているため、それらのバイナリは必ず異なります。しかし、両方のGo実装がバグがなく、正しい実装である場合、toolchain1とtoolchain2はまったく同じ動作をするはずです。特に、Go 1.Xソースが提示された場合、toolchain1の出力(toolchain2)とtoolchain2の出力(toolchain3)は同一である必要があり、つまりtoolchain2とtoolchain3は同一である必要があります。

少なくとも、それがアイデアです。それを実際に行うには、いくつかの意図しない入力を削除する必要がありました。

ランダム性。マップの反復処理と、ロックを使用してシリアル化された複数のゴルーチンで作業を実行すると、結果が生成される順序にランダム性が導入されます。このランダム性により、ツールチェーンは実行するたびに、いくつかの異なる出力の1つを生成する可能性があります。ビルドを再現可能にするには、それらのそれぞれを見つけ、関連するアイテムのリストをソートしてから、それを出力の生成に使用しなければなりませんでした。

ブートストラップライブラリ。複数の異なる正しい出力から選択できるコンパイラで使用されるライブラリは、Goのバージョンごとにその出力が変わる可能性があります。そのライブラリ出力の変更がコンパイラ出力の変更を引き起こした場合、toolchain1とtoolchain2は意味的に同一ではなくなり、toolchain2とtoolchain3はビット単位で同一ではなくなります。

典型的な例はsortパッケージで、これは等しく比較される要素を任意の順序で配置できます。レジスタアロケータは、一般的に使用される変数を優先するためにソートし、リンカーはデータセクション内のシンボルをサイズでソートします。ソートアルゴリズムによる影響を完全に排除するには、使用される比較関数は、2つの異なる要素を等しいと報告してはなりません。実際には、この不変条件をツールチェーンのすべてのsortの使用に課すのはあまりにも負担が大きいため、代わりにGo 1.Xのsortパッケージをブートストラップコンパイラに提示されるソースツリーにコピーするように手配しました。そうすれば、コンパイラは、ブートストラップツールチェーンを使用する場合と、それ自体でビルドする場合とで、同じソートアルゴリズムを使用します。

複製が必要だったもう1つのパッケージはcompress/zlibです。リンカは圧縮されたデバッグ情報を書き込むため、圧縮ライブラリの最適化によって出力結果が正確に変化する可能性があります。長年にわたり、このリストに他のパッケージも追加してきました。このアプローチには、Go 1.Xコンパイラがそれらのパッケージに追加された新しいAPIをすぐに使用できるという利点がありますが、それらのパッケージは古いバージョンのGoでもコンパイルできるように記述する必要があるという欠点もあります。

Go 1.20における再現性

Go 1.20の作業では、ツールチェーンビルドからさらに2つの関連入力を削除することにより、容易な再現可能なビルドとツールチェーン管理の両方に備えました。

ホストCツールチェーン。一部のGoパッケージ、特にnetは、ほとんどのオペレーティングシステムでcgoを使用することをデフォルトとしています。macOSやWindowsなど、一部のケースでは、cgoを使用してシステムDLLを呼び出すことが、ホスト名を解決する唯一の信頼できる方法です。しかし、cgoを使用する場合、ホストCツールチェーン(特定のCコンパイラとCライブラリを意味します)を呼び出すことになり、ツールチェーンが異なると、コンパイルアルゴリズムとライブラリコードが異なり、異なる出力が生成されます。cgoパッケージのビルドグラフは次のようになります。

したがって、ホストCツールチェーンは、ツールチェーンに付属するプリコンパイルされたnet.aの関連入力となります。Go 1.20では、ツールチェーンからnet.aを削除することでこれを修正することにしました。つまり、Go 1.20は、ビルドキャッシュをシードするプリコンパイルされたパッケージの出荷を停止しました。プログラムがパッケージnetを初めて使用すると、GoツールチェーンはローカルシステムのCツールチェーンを使用してコンパイルし、その結果をキャッシュします。ツールチェーンビルドからの関連入力の削除とツールチェーンダウンロードの小型化に加えて、プリコンパイルされたパッケージを出荷しないことで、ツールチェーンダウンロードの移植性も向上します。あるシステムで1つのCツールチェーンを使用してパッケージnetをビルドし、別のシステムで別のCツールチェーンを使用してプログラムの他の部分をコンパイルした場合、一般的に、2つの部分をリンクできるという保証はありません。

そもそもプリコンパイルされたnetパッケージを出荷した理由の1つは、Cツールチェーンがインストールされていないシステムでも、パッケージnetを使用するプログラムをビルドできるようにするためでした。プリコンパイルされたパッケージがない場合、それらのシステムでは何が起こるでしょうか? 答えはオペレーティングシステムによって異なりますが、いずれの場合も、GoツールチェーンがホストCツールチェーンなしで純粋なGoプログラムのビルドを適切に動作するように配置しました。

  • macOSでは、実際のCコードを使用せずに、cgoが使用する基盤となるメカニズムを使用してパッケージnetを書き直しました。これにより、ホストCツールチェーンの呼び出しは回避されますが、必要なシステムDLLを参照するバイナリは引き続き出力されます。このアプローチは、すべてのMacに同じ動的ライブラリがインストールされているためのみ可能です。非cgoのmacOSパッケージnetでシステムDLLを使用するように変更したことで、クロスコンパイルされたmacOS実行ファイルはネットワークアクセスにシステムDLLを使用するようになり、長年の機能要求が解決されました。

  • Windowsでは、パッケージnetはすでにCコードなしでDLLを直接使用していたため、変更する必要はありませんでした。

  • Unixシステムでは、ネットワークコードに対する特定のDLLインターフェースを想定することはできませんが、純粋なGoバージョンは、一般的なIPとDNSの設定を使用するシステムで正常に動作します。また、Unixシステムでは、macOS、特にWindowsよりもCツールチェーンをインストールする方がはるかに簡単です。システムにCツールチェーンがインストールされているかどうかを基に、goコマンドがcgoを自動的に有効または無効にするように変更しました。CツールチェーンがないUnixシステムは、純粋なGoバージョンのパッケージnetにフォールバックし、まれにそれで不十分な場合は、Cツールチェーンをインストールできます。

プリコンパイルされたパッケージを削除したため、ホストCツールチェーンに依存していたGoツールチェーンの唯一の部分は、パッケージnetを使用してビルドされたバイナリ、特にgoコマンドでした。macOSの改善により、cgoを無効にしてこれらのコマンドをビルドすることが可能になり、ホストCツールチェーンを入力として完全に削除することができましたが、その最終的なステップはGo 1.21に残されました。

ホスト動的リンカ。動的にリンクされたCライブラリを使用するシステムでプログラムがcgoを使用する場合、結果のバイナリには、システムの動的リンカへのパス(例:/lib64/ld-linux-x86-64.so.2)が含まれます。パスが間違っていると、バイナリは実行されません。通常、各オペレーティングシステム/アーキテクチャの組み合わせには、このパスに対する正しい答えが1つだけあります。残念ながら、Alpine LinuxなどのmuslベースのLinuxは、UbuntuなどのglibcベースのLinuxとは異なる動的リンカを使用しています。Alpine LinuxでGoを実行するには、Goブートストラッププロセスは次のようになります。

ブートストラッププログラムcmd/distは、ローカルシステムの動的リンカを検査し、その値を新しいソースファイルに書き込み、リンカのソースの残りの部分と一緒にコンパイルしました。これにより、そのデフォルトがリンカ自体にハードコーディングされました。次に、リンカがコンパイル済みパッケージのセットからプログラムをビルドすると、そのデフォルトが使用されました。その結果、AlpineでビルドされたGoツールチェーンは、Ubuntuでビルドされたツールチェーンとは異なります。ホスト構成はツールチェーンビルドの関連入力です。これは再現性の問題であるだけでなく、移植性の問題でもあります。AlpineでビルドされたGoツールチェーンは、Ubuntuで動作するバイナリをビルドしたり、実行したりすることはできません。その逆も同様です。

Go 1.20では、ツールチェーンビルド時にハードコーディングされたデフォルトではなく、実行時にホスト構成を参照するようにリンカを変更することで、再現性の問題を解決するためのステップを踏みました。

これにより、Alpine Linuxでのリンカビナリの実行時移植性は向上しましたが、goコマンドはまだパッケージnet、つまりcgoを使用し、そのため独自のバイナリに動的リンカへの参照が含まれていたため、全体的なツールチェーンの移植性は向上しませんでした。前のセクションと同様に、cgoを無効にしてgoコマンドをコンパイルすることでこれを修正できますが、その変更はGo 1.21に残しました。(Go 1.20サイクルに残された時間で、そのような変更を適切にテストする時間がないと感じました。)

Go 1.21における再現性

Go 1.21では、完全な再現性の目標が視野に入り、残りの、ほとんどが小さな関連入力を処理しました。

ホストCツールチェーンと動的リンカ。上記で説明したように、Go 1.20は、ホストCツールチェーンと動的リンカを関連入力として削除するための重要なステップを踏みました。Go 1.21は、cgoを無効にしてツールチェーンをビルドすることで、これらの関連入力の削除を完了しました。これにより、ツールチェーンの移植性も向上しました。Go 1.21は、標準のGoツールチェーンがAlpine Linuxシステムで変更せずに実行される最初のGoリリースです。

これらの関連入力を削除することで、機能を損なうことなく、別のシステムからGoツールチェーンをクロスコンパイルすることが可能になりました。これにより、Goツールチェーンのサプライチェーンセキュリティも向上しました。すべてのターゲットシステムのGoツールチェーンを、信頼できるLinux/x86-64システムを使用して構築できるようになり、各ターゲットごとに個別の信頼できるシステムを用意する必要がなくなりました。その結果、Go 1.21は、go.dev/dl/ですべてのシステムの投稿済みバイナリが含まれる最初のリリースです。

ソースディレクトリ。Goプログラムには、ランタイムとデバッグメタデータに完全なパスが含まれているため、プログラムがクラッシュした場合やデバッガで実行された場合、スタックトレースには、不明なディレクトリ内のファイル名ではなく、ソースファイルへの完全なパスが含まれます。残念ながら、完全なパスを含めることで、ソースコードが保存されているディレクトリがビルドの関連入力になります。これを修正するために、Go 1.21では、リリースツールチェーンビルドで、コンパイラなどのコマンドをgo install -trimpathを使用してインストールするように変更しました。これにより、ソースディレクトリがコードのモジュールパスに置き換えられます。リリースされたコンパイラがクラッシュした場合、スタックトレースは/home/user/go/src/cmd/compile/main.goではなくcmd/compile/main.goのようなパスを出力します。完全なパスはとにかく別のマシンのディレクトリを参照するため、この書き換えは損失ではありません。一方、リリース以外のビルドでは、完全なパスを維持するため、コンパイラ自体に取り組んでいる開発者がクラッシュを引き起こした場合、それらのクラッシュを読み取るIDEやその他のツールは、正しいソースファイルを簡単に検索できます。

ホストオペレーティングシステム。Windowsシステムのパスは、cmd\compile\main.goのようにバックスラッシュで区切られています。他のシステムは、cmd/compile/main.goのようにスラッシュを使用します。以前のバージョンのGoでは、これらのパスのほとんどがスラッシュを使用するように正規化されていましたが、わずかな不一致が再び発生し、Windowsでわずかに異なるツールチェーンビルドが発生していました。そのバグを発見し、修正しました。

ホストアーキテクチャ。GoはさまざまなARMシステムで動作し、浮動小数点数学用のソフトウェアライブラリ(SWFP)またはハードウェア浮動小数点命令(HWFP)を使用してコードを出力できます。一方のモードをデフォルトとするツールチェーンは、必ずしも異なります。前述の動的リンカと同様に、Goブートストラッププロセスはビルドシステムを検査して、結果のツールチェーンがそのシステムで動作することを確認しました。歴史的な理由から、ルールは「ビルドが浮動小数点ハードウェアを搭載したARMシステムで実行されていない限り、SWFPを想定する」というものであり、クロスコンパイルされたツールチェーンはSWFPを想定していました。今日のARMシステムの大部分は浮動小数点ハードウェアを備えているため、これはネイティブコンパイルされたツールチェーンとクロスコンパイルされたツールチェーンの間に不要な違いをもたらしました。さらに、Windows ARMビルドは常にHWFPを想定していたため、決定がオペレーティングシステムに依存していました。ルールを「ビルドが浮動小数点ハードウェアを搭載していないARMシステムで実行されていない限り、HWFPを想定する」に変更しました。これにより、クロスコンパイルと最新のARMシステムでのビルドで、同一のツールチェーンが生成されます。

パッケージングロジック。ダウンロードのために投稿する実際のツールチェーンアーカイブを作成するすべてのコードは、別のGitリポジトリgolang.org/x/buildにあり、アーカイブのパッケージング方法の詳細は時間の経過とともに変化します。これらのアーカイブを再現したい場合、そのリポジトリの正しいバージョンが必要です。アーカイブをパッケージ化するコードをメインのGoソースツリーのcmd/distpackに移動することにより、この関連入力を削除しました。Go 1.21以降、特定のバージョンのGoのソースがあれば、アーカイブをパッケージ化するソースも含まれています。golang.org/x/buildリポジトリは、関連入力ではなくなりました。

ユーザーID。ダウンロードのために投稿されたtarアーカイブは、ファイルシステムに書き込まれたディストリビューションからビルドされており、tar.FileInfoHeaderを使用すると、ファイルシステムからtarファイルにユーザーとグループIDがコピーされるため、ビルドを実行するユーザーが関連入力になります。これらをクリアするようにアーカイブコードを変更しました。

現在時刻。ユーザーIDと同様に、ダウンロードのために投稿されたtarおよびzipアーカイブは、ファイルシステムの最終変更時刻をアーカイブにコピーしてビルドされていたため、現在時刻が関連入力になっていました。時刻をクリアすることもできますが、UnixまたはMS-DOSのゼロ時刻を使用すると、驚くべき結果になったり、一部のツールが壊れる可能性があると考えました。代わりに、リポジトリに保存されているgo/VERSIONファイルを変更し、そのバージョンに関連付けられた時刻を追加しました。

$ cat go1.21.0/VERSION
go1.21.0
time 2023-08-04T20:14:06Z
$

パッケージャは、ローカルファイルの最終変更時刻をコピーする代わりに、VERSIONファイルから時刻をコピーしてアーカイブにファイルを書き込むようになりました。

暗号署名キー。macOS用のGoツールチェーンは、Appleが承認した署名キーでバイナリに署名しない限り、エンドユーザーシステムでは実行されません。Googleの署名キーで署名するために内部システムを使用しており、明らかに、他の人が署名済みバイナリを再現できるようにするために、その秘密キーを共有することはできません。代わりに、署名を除いて2つのバイナリが同一であるかどうかを確認できる検証プログラムを作成しました。

OS固有のパッケージャ。ダウンロード可能なmacOS PKGインストーラを作成するためにXcodeツールpkgbuildproductbuildを使用し、ダウンロード可能なWindows MSIインストーラを作成するためにWiXを使用しています。検証プログラムに同じバージョンのツールを必要としないように、暗号署名キーと同じアプローチを取り、パッケージの内容を調べ、ツールチェーンファイルが期待どおりであることを確認できる検証プログラムを作成しました。

Goツールチェインの検証

Goツールチェインを一度再現可能にするだけでは不十分です。再現性を維持し、他の人も容易に再現できるようにする必要があります。

誠実さを保つために、現在、すべてのGo配布物を信頼できるLinux/x86-64システムとWindows/x86-64システムの両方でビルドしています。アーキテクチャを除いて、2つのシステムはほとんど共通点がありません。2つのシステムはビット単位で同一のアーカイブを生成する必要があります。そうでなければ、リリースを続行しません。

私たちが誠実であることを他の人が検証できるように、検証ツールgolang.org/x/build/cmd/gorebuildを作成し、公開しました。このプログラムは、私たちのGitリポジトリにあるソースコードから開始し、現在のGoバージョンを再ビルドして、go.dev/dlに投稿されたアーカイブと一致するかどうかを確認します。ほとんどのアーカイブはビット単位で一致する必要があります。前述のように、より緩やかなチェックが使用される3つの例外があります。

  • macOS tar.gzファイルは異なることが予想されますが、検証ツールは内部の内容を比較します。再ビルドされたコピーと投稿されたコピーは、同じファイルを含んでいなければならず、すべてのファイルは実行可能バイナリを除いて完全に一致する必要があります。実行可能バイナリは、コード署名を削除した後、完全に一致する必要があります。

  • macOS PKGインストーラは再ビルドされません。代わりに、検証ツールはPKGインストーラ内のファイルを読み取り、コード署名を削除した後、macOS tar.gzと完全に一致することを確認します。長期的に見ると、PKGの作成は非常に簡単なので、cmd/distpackに追加できる可能性がありますが、検証ツールは依然としてコード署名が無視された実行可能ファイルの比較を実行するためにPKGファイルを解析する必要があります。

  • Windows MSIインストーラは再ビルドされません。代わりに、検証ツールはLinuxプログラムmsiextractを呼び出して内部のファイルを抽出し、再ビルドされたWindows zipファイルと完全に一致することを確認します。長期的に見ると、MSIの作成をcmd/distpackに追加し、検証ツールでビット単位のMSI比較を使用できるようになるかもしれません。

gorebuildを毎晩実行し、結果をgo.dev/rebuildに投稿しています。もちろん、誰でも実行できます。

UbuntuのGoツールチェインの検証

Goツールチェインの容易に再現可能なビルドにより、go.devに投稿されたツールチェイン内のバイナリは、パッケージャがソースからビルドする場合でも、他のパッケージングシステムに含まれるバイナリと一致するはずです。パッケージャが異なる構成やその他の変更を使用してコンパイルした場合でも、容易に再現可能なビルドにより、それらのバイナリを容易に再現できます。これを示すために、Ubuntuのgolang-1.21パッケージバージョン1.21.0-1(Linux/x86-64)を再現してみましょう。

まず、zstdで圧縮されたtarアーカイブを含むar(1)アーカイブであるUbuntuパッケージをダウンロードして展開する必要があります。

$ mkdir deb
$ cd deb
$ curl -LO http://mirrors.kernel.org/ubuntu/pool/main/g/golang-1.21/golang-1.21-src_1.21.0-1_all.deb
$ ar xv golang-1.21-src_1.21.0-1_all.deb
x - debian-binary
x - control.tar.zst
x - data.tar.zst
$ unzstd < data.tar.zst | tar xv
...
x ./usr/share/go-1.21/src/archive/tar/common.go
x ./usr/share/go-1.21/src/archive/tar/example_test.go
x ./usr/share/go-1.21/src/archive/tar/format.go
x ./usr/share/go-1.21/src/archive/tar/fuzz_test.go
...
$

これがソースアーカイブです。次にamd64バイナリアーカイブです。

$ rm -f debian-binary *.zst
$ curl -LO http://mirrors.kernel.org/ubuntu/pool/main/g/golang-1.21/golang-1.21-go_1.21.0-1_amd64.deb
$ ar xv golang-1.21-src_1.21.0-1_all.deb
x - debian-binary
x - control.tar.zst
x - data.tar.zst
$ unzstd < data.tar.zst | tar xv | grep -v '/$'
...
x ./usr/lib/go-1.21/bin/go
x ./usr/lib/go-1.21/bin/gofmt
x ./usr/lib/go-1.21/go.env
x ./usr/lib/go-1.21/pkg/tool/linux_amd64/addr2line
x ./usr/lib/go-1.21/pkg/tool/linux_amd64/asm
x ./usr/lib/go-1.21/pkg/tool/linux_amd64/buildid
...
$

Ubuntuは通常のGoツリーを/usr/share/go-1.21/usr/lib/go-1.21の2つの半分に分割します。これらをまとめてみましょう。

$ mkdir go-ubuntu
$ cp -R usr/share/go-1.21/* usr/lib/go-1.21/* go-ubuntu
cp: cannot overwrite directory go-ubuntu/api with non-directory usr/lib/go-1.21/api
cp: cannot overwrite directory go-ubuntu/misc with non-directory usr/lib/go-1.21/misc
cp: cannot overwrite directory go-ubuntu/pkg/include with non-directory usr/lib/go-1.21/pkg/include
cp: cannot overwrite directory go-ubuntu/src with non-directory usr/lib/go-1.21/src
cp: cannot overwrite directory go-ubuntu/test with non-directory usr/lib/go-1.21/test
$

エラーはシンボリックリンクのコピーについて文句を言っていますが、無視できます。

次に、アップストリームのGoソースをダウンロードして展開する必要があります。

$ curl -LO https://go.googlesource.com/go/+archive/refs/tags/go1.21.0.tar.gz
$ mkdir go-clean
$ cd go-clean
$ curl -L https://go.googlesource.com/go/+archive/refs/tags/go1.21.0.tar.gz | tar xzv
...
x src/archive/tar/common.go
x src/archive/tar/example_test.go
x src/archive/tar/format.go
x src/archive/tar/fuzz_test.go
...
$

試行錯誤をスキップするために、UbuntuはGO386=softfloatを使用してGoをビルドし、32ビットx86用のコンパイル時にソフトウェア浮動小数点の使用を強制し、結果のELFバイナリから(シンボルテーブルを)削除します。GO386=softfloatビルドから始めましょう。

$ cd src
$ GOOS=linux GO386=softfloat ./make.bash -distpack
Building Go cmd/dist using /Users/rsc/sdk/go1.17.13. (go1.17.13 darwin/amd64)
Building Go toolchain1 using /Users/rsc/sdk/go1.17.13.
Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1.
Building Go toolchain2 using go_bootstrap and Go toolchain1.
Building Go toolchain3 using go_bootstrap and Go toolchain2.
Building commands for host, darwin/amd64.
Building packages and commands for target, linux/amd64.
Packaging archives for linux/amd64.
distpack: 818d46ede85682dd go1.21.0.src.tar.gz
distpack: 4fcd8651d084a03d go1.21.0.linux-amd64.tar.gz
distpack: eab8ed80024f444f v0.0.1-go1.21.0.linux-amd64.zip
distpack: 58528cce1848ddf4 v0.0.1-go1.21.0.linux-amd64.mod
distpack: d8da1f27296edea4 v0.0.1-go1.21.0.linux-amd64.info
---
Installed Go for linux/amd64 in /Users/rsc/deb/go-clean
Installed commands in /Users/rsc/deb/go-clean/bin
*** You need to add /Users/rsc/deb/go-clean/bin to your PATH.
$

これにより、標準パッケージがpkg/distpack/go1.21.0.linux-amd64.tar.gzに残りました。展開して、Ubuntuと一致するようにバイナリを削除しましょう。

$ cd ../..
$ tar xzvf go-clean/pkg/distpack/go1.21.0.linux-amd64.tar.gz
x go/CONTRIBUTING.md
x go/LICENSE
x go/PATENTS
x go/README.md
x go/SECURITY.md
x go/VERSION
...
$ elfstrip go/bin/* go/pkg/tool/linux_amd64/*
$

これで、Macで作成したGoツールチェインと、Ubuntuが提供するGoツールチェインを比較できます。

$ diff -r go go-ubuntu
Only in go: CONTRIBUTING.md
Only in go: LICENSE
Only in go: PATENTS
Only in go: README.md
Only in go: SECURITY.md
Only in go: codereview.cfg
Only in go: doc
Only in go: lib
Binary files go/misc/chrome/gophertool/gopher.png and go-ubuntu/misc/chrome/gophertool/gopher.png differ
Only in go-ubuntu/pkg/tool/linux_amd64: dist
Only in go-ubuntu/pkg/tool/linux_amd64: distpack
Only in go/src: all.rc
Only in go/src: clean.rc
Only in go/src: make.rc
Only in go/src: run.rc
diff -r go/src/syscall/mksyscall.pl go-ubuntu/src/syscall/mksyscall.pl
1c1
< #!/usr/bin/env perl
---
> #! /usr/bin/perl
...
$

Ubuntuパッケージの実行可能ファイルを正常に再現し、残っている変更の完全なセットを特定しました。

  • さまざまなメタデータとサポートファイルが削除されました。
  • gopher.pngファイルが変更されました。詳しく調べると、Ubuntuが更新した埋め込みタイムスタンプを除いて、2つは同一です。おそらく、Ubuntuのパッケージ化スクリプトは、既存の圧縮を改善できない場合でもタイムスタンプを書き換えるツールを使用してpngを再圧縮しているのでしょう。
  • ブートストラップ中にビルドされるが標準アーカイブには含まれていないバイナリdistdistpackがUbuntuパッケージに含まれていました。
  • Plan 9ビルドスクリプト(*.rc)は削除されましたが、Windowsビルドスクリプト(*.bat)は残っています。
  • mksyscall.plおよび他の7つの表示されていないPerlスクリプトのヘッダーが変更されました。

特に、ツールチェインのバイナリをビット単位で再構築したことに注意してください。差分にはまったく表示されません。つまり、UbuntuのGoバイナリがアップストリームのGoソースと完全に一致することを証明しました。

さらに良いことに、Ubuntuソフトウェアをまったく使用せずにこれを証明しました。これらのコマンドはMacで実行され、unzstdelfstripは短いGoプログラムです。洗練された攻撃者は、パッケージ作成ツールを変更することで、悪意のあるコードをUbuntuパッケージに挿入する可能性があります。そうした場合、それらの悪意のあるツールを使用してクリーンなソースからGo Ubuntuパッケージを再現しても、悪意のあるパッケージのビット単位で同一のコピーが生成されます。この攻撃は、Ken Thompsonのコンパイラ攻撃と同様に、その種の再ビルドでは見えません。Ubuntuソフトウェアをまったく使用せずにUbuntuパッケージを検証することは、はるかに強力なチェックです。ホストオペレーティングシステム、ホストアーキテクチャ、ホストCツールチェインなどの意図しない詳細に依存しない、Goの完全に再現可能なビルドにより、この強力なチェックが可能になります。

(歴史的記録のための余談ですが、Ken Thompsonはかつて、彼の攻撃は実際には検出されたと私に言いました。コンパイラビルドが再現できなくなったためです。バグがありました。コンパイラに追加されたバックドアの文字列定数は不完全に処理され、コンパイラが自身をコンパイルするたびに1バイトのNULバイトずつ増加しました。最終的に、再現不可能なビルドに気づいた人が原因をアセンブリにコンパイルすることで探そうとしました。コンパイラのバックドアはアセンブリ出力にまったく再現されなかったため、その出力をアセンブルすることでバックドアが削除されました。)

結論

再現可能なビルドは、オープンソースサプライチェーンを強化するための重要なツールです。SLSAのようなフレームワークは、信頼に関する意思決定を知らせるために使用できる、来歴とソフトウェアの監査証跡に焦点を当てています。再現可能なビルドは、信頼が適切に配置されていることを検証する方法を提供することにより、そのアプローチを補完します。

完全な再現性(ソースファイルがビルドの唯一の関連入力である場合)は、コンパイラツールチェインのように、自身をビルドするプログラムでのみ可能です。自己ホスティングコンパイラツールチェインは、そうでなければ検証が非常に困難であるため、それは高尚ながらも価値のある目標です。Goの完全な再現性により、パッケージャがソースコードを変更しない限り、任意の形式でのLinux/x86-64(お気に入りのシステムを代用してください)向けのGo 1.21.0のすべてのパッケージングは、すべてソースからビルドする場合でも、まったく同じバイナリを配布する必要があります。これはUbuntu Linuxではまったく正しくないことがわかりましたが、完全な再現性により、非常に異なる非Ubuntuシステムを使用してUbuntuパッケージングを再現できます。

理想的には、バイナリ形式で配布されるすべてのオープンソースソフトウェアは、容易に再現可能なビルドを持つ必要があります。実際には、この記事で見たように、意図しない入力がビルドに漏れることは非常に簡単です。cgoを必要としないGoプログラムの場合、再現可能なビルドはCGO_ENABLED=0 go build -trimpathでコンパイルするほど簡単です。cgoを無効にすると、ホストCツールチェインが関連入力として削除され、-trimpathにより現在のディレクトリが削除されます。プログラムでcgoが必要な場合は、特定の仮想マシンまたはコンテナイメージでビルドを実行するなど、go buildを実行する前に特定のホストCツールチェインバージョンを準備する必要があります。

Goを超えて、Reproducible Buildsプロジェクトは、すべてのオープンソースの再現性を向上させることを目的としており、独自のソフトウェアビルドを再現可能にするための詳細情報を入手するための良い出発点です。

次の記事:Go 1.21のプロファイルガイド付き最適化
前の記事:slogを使用した構造化ログ
ブログインデックス