The Go Blog

完全に再現可能で検証済みの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ブートストラップコンパイラを含む、任意のGoブートストラップコンパイラを使用でき、その他の詳細も変更できます。それらのどれも、ビルドされるツールチェーンを変更しません。同じツールチェーンソースコードから開始すれば、まったく同じツールチェーンバイナリが得られます。

この完全な再現可能性は、元々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」を生成します。次に「toolchain2」を使用してすべてを再度ビルドし、「toolchain3」を生成します。

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

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

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

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

典型的な例は`sort`パッケージで、比較が等しい要素を任意の順序で配置できます。レジスタアロケータは、一般的に使用される変数を優先するためにソートする可能性があり、リンカはデータセクションのシンボルをサイズでソートします。ソートアルゴリズムによる影響を完全に排除するために、使用される比較関数は、2つの異なる要素を等しいと報告してはなりません。実際には、この不変条件をツールチェーンのソートのすべての使用に課すのはあまりにも手間がかかることが判明したため、代わりに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では、`cgo`が使用する基盤となるメカニズムを使用して、実際のCコードなしで`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システムは、`net`パッケージの純粋なGoバージョンにフォールバックし、それが十分でないまれなケースでは、Cツールチェーンをインストールできます。

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

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

`cmd/dist`というブートストラッププログラムは、ローカルシステムの動的リンカーを検査し、その値を残りのリンカーソースと一緒にコンパイルされる新しいソースファイルに書き込み、そのデフォルトをリンカー自体に効果的にハードコーディングしました。次に、リンカーがコンパイルされたパッケージのセットからプログラムをビルドするとき、そのデフォルトを使用しました。結果として、AlpineでビルドされたGoツールチェーンはUbuntuでビルドされたツールチェーンとは異なりました。ホスト構成がツールチェーンビルドへの関連する入力でした。これは再現性の問題であると同時に移植性の問題でもありました。AlpineでビルドされたGoツールチェーンは、Ubuntuで動作するバイナリをビルドすることも、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ツールチェーンのサプライチェーンセキュリティが向上しました。各ターゲットに対して個別の信頼されたシステムを用意する必要なく、信頼されたLinux/x86-64システムを使用してすべてのターゲットシステム用のGoツールチェーンをビルドできるようになりました。その結果、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の以前のバージョンでは、これらのパスのほとんどをスラッシュを使用するように正規化していましたが、1つの矛盾が再び忍び込み、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`はファイルシステムのユーザーIDとグループIDをtarファイルにコピーし、ビルドを実行するユーザーを関連する入力にしていました。これをクリアするようにアーカイブコードを変更しました。

現在の時刻。ユーザー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ツール`pkgbuild`と`productbuild`を使用し、ダウンロード可能な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に投稿されたツールチェーンのバイナリが、たとえそれらのパッケージャーがソースからビルドした場合でも、他のパッケージングシステムに含まれるバイナリと一致することを意味するはずです。たとえパッケージャーが異なる構成やその他の変更でコンパイルした場合でも、簡単に再現可能なビルドは、それらのバイナリを簡単に再現できるようにするはずです。これを実証するために、Linux/x86-64用のUbuntu `golang-1.21`パッケージバージョン`1.21.0-1`を再現してみましょう。

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

$ 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ツリーを2つの半分に分割し、/usr/share/go-1.21と/usr/lib/go-1.21に配置します。それらを元に戻しましょう。

$ 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はGoを`GO386=softfloat`でビルドしていることがわかりました。これは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を再圧縮したのでしょう。
  • ブートストラップ中にビルドされるが、標準アーカイブには含まれない`dist`と`distpack`バイナリがUbuntuパッケージに含まれていました。
  • Plan 9のビルドスクリプト(`*.rc`)は削除されましたが、Windowsのビルドスクリプト(`*.bat`)は残っています。
  • `mksyscall.pl`と、表示されていない7つの他のPerlスクリプトのヘッダーが変更されました。

特に、ツールチェーンバイナリをビット単位で再構築したことに注意してください。それらはdiffにまったく表示されません。つまり、Ubuntu Goバイナリが上流のGoソースに正確に対応することを証明しました。

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

(余談ですが、ケン・トンプソンはかつて、彼の攻撃は実際に検出されたと私に言いました。なぜなら、コンパイラのビルドが再現不能になったからです。コンパイラに追加されたバックドアの文字列定数が不完全に処理され、コンパイラが自身をコンパイルするたびに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以外にも、再現可能なビルドプロジェクトは、すべてのオープンソースの再現可能性を向上させることを目指しており、自身のソフトウェアビルドを再現可能にするためのより多くの情報を見つける良い出発点となります。

次の記事: Go 1.21でのプロファイルガイド付き最適化
前の記事: slogによる構造化ロギング
ブログインデックス