完全に再現可能で検証可能なGoツールチェイン

Perfectly Reproducible, Verified Go Toolchains by Russ Cox

オープンソースソフトウェアの重要な利点の一つは、誰でもソースコードを読んでその動作を検証できることです。 それにもかかわらず、ほとんどのソフトウェアは、たとえオープンソースであっても、コンパイル済みのバイナリの形で 配布されており、バイナリはソースコードに比べてはるかに検証が難しいものです。 もし攻撃者がオープンソースプロジェクトに対してサプライチェーン攻撃を仕掛けようとするなら、 最も気づかれにくい方法は、ソースコードはそのままにして、配布されているバイナリだけを差し替えることでしょう。

この種の攻撃に対処する最良の方法は、オープンソースソフトウェアのビルドを 再現可能 にすることです。 つまり、同じソースコードから始まったビルドは、実行するたびに同じ成果物を生成するということです。 そうすれば、誰でも正規のソースコードからビルドを行い、再ビルドしたバイナリが公開されているバイナリと ビット単位で一致することを確認するだけで、公開されているバイナリに隠された変更がないことを検証できます。 このアプローチでは、バイナリを逆アセンブルしたり中身を覗いたりすることなく、ソースコードに存在しない バックドアやその他の変更がバイナリに含まれていないことを証明できます。誰でもバイナリを検証できるので、 独立した第三者がサプライチェーン攻撃を容易に検出し報告できます。

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

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

この記事では、ビルドを再現可能にするために何が必要かを説明し、Goのツールチェインを再現可能にするために 私たちが行った数々の変更を検証し、そして再現可能性がもたらす利点の一つとして、Go 1.21.0のUbuntuパッケージを 実際に検証してみせます。

ビルドを再現可能にする

コンピュータは一般に決定的に動作するので、すべてのビルドは等しく再現可能だと思うかもしれません。 しかし、それはある観点から見た場合にのみ正しいと言えます。ビルドの出力がある入力によって変化しうるとき、 その情報を 関連する入力 と呼ぶことにしましょう。ビルドは、すべての関連する入力を同じにして繰り返すことが できるなら再現可能です。残念なことに、多くのビルドツールは、私たちが通常は関連するとは思っていない、 そして再現したり入力として与えたりするのが難しいかもしれない入力を、実は取り込んでしまっています。 ある入力が関連していることが判明したものの、私たちがそれを意図していなかった場合、それを 意図しない入力 と呼ぶことにしましょう。

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

ビルドにおいてよくある関連する入力には、次のようなものがあります。

  • ビルド対象のソースコードの特定のバージョン
  • ビルドに含まれる依存関係の特定のバージョン
  • ビルドを実行するオペレーティングシステム(生成されるバイナリ内のパス名に影響することがある)
  • ビルドシステムのCPUアーキテクチャ(コンパイラが使う最適化や、特定のデータ構造のレイアウトに影響することがある)
  • 使用しているコンパイラのバージョンと、それに渡されるコンパイラオプション(コードのコンパイルのされ方に影響する)
  • ソースコードを含んでいるディレクトリの名前(デバッグ情報に現れることがある)
  • ビルドを実行しているアカウントのユーザー名、グループ名、uid、gid(アーカイブ内のファイルのメタデータに現れることがある)
  • その他多数

再現可能なビルドを実現するには、関連するすべての入力をビルドの中で設定可能にした上で、関連するすべての 入力を列挙した明示的な設定とともにバイナリを公開する必要があります。それができれば、再現可能なビルドが 手に入ります。おめでとうございます!

しかし、これで終わりではありません。もしバイナリを再現するために、まず正しいアーキテクチャのコンピュータを 見つけ、特定のバージョンのオペレーティングシステムとコンパイラをインストールし、ソースコードを正しい ディレクトリに置き、ユーザーの識別情報を正しく設定する、といった作業が必要だとしたら、実際には誰も そんな手間をかけようとはしないかもしれません。

私たちはビルドが再現可能であるだけでなく、 簡単に再現できる ものであってほしいのです。そのためには、 関連する入力を特定した上で、それを文書化するのではなく、なくしてしまう必要があります。ビルドはビルド対象の ソースコードに依存せざるを得ませんが、それ以外のものはすべて排除できます。ビルドにとって関連する入力が ソースコードだけであるとき、それを 完全に再現可能 と呼ぶことにしましょう。

Goにおける完全に再現可能なビルド

Go 1.21以降、Goのツールチェインは完全に再現可能になりました。関連する入力はそのビルド対象の ソースコードだけです。特定のツールチェイン(たとえばLinux/x86-64向けのGo)をLinux/x86-64のホスト上で ビルドしても、Windows/ARM64のホスト、あるいはFreeBSD/386のホスト、その他Goをサポートする任意のホスト上で ビルドしても構いませんし、任意のGoブートストラップコンパイラを使うこともできます。それはGo 1.4のC実装まで 遡ってブートストラップする場合も含まれます。そしてその他の詳細をどのように変えても構いません。それらは ビルドされるツールチェインには一切影響しません。同じツールチェインのソースコードから始めれば、まったく 同じツールチェインのバイナリが得られます。

この完全な再現可能性は、もともとGo 1.10まで遡る取り組みの集大成ですが、大半の作業はGo 1.20とGo 1.21に 集中していました。このセクションでは、私たちが排除した関連する入力のうち特に興味深いものをいくつか 紹介します。

Go 1.10における再現可能性

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

rebuild_bootstrap

まず、現在のGoツールチェインのソースコードを、それより前のGoバージョンであるブートストラップツールチェイン (Go 1.10ではC実装のGo 1.4を、Go 1.21ではGo 1.17を使用)を使ってビルドします。これによって「toolchain1」が 生成され、それを使ってすべてを再びビルドすると「toolchain2」が生成され、それを使ってもう一度すべてを ビルドすると「toolchain3」が生成されます。

toolchain1とtoolchain2は同じソースコードからビルドされていますが、異なるGoの実装(コンパイラとライブラリ)を 使っているため、それらのバイナリが異なるのは当然です。しかし、両方のGo実装にバグがなく正しいものであれば、 toolchain1とtoolchain2はまったく同じように振る舞うはずです。特に、Go 1.Xのソースコードを与えたとき、 toolchain1の出力(toolchain2)とtoolchain2の出力(toolchain3)は同一になるはずであり、つまりtoolchain2と toolchain3は同一になるはずです。

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

ランダム性。 マップのイテレーションや、ロックによって直列化された複数のゴルーチンでの作業の実行は、 どちらも結果が生成される順序にランダム性を持ち込みます。このランダム性により、ツールチェインは実行するたびに いくつかの異なる出力のいずれかを生成する可能性があります。ビルドを再現可能にするために、私たちはこうした 箇所をすべて見つけ出し、出力の生成に使う前に対象のリストをソートするようにしました。

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

代表的な例はsortパッケージで、これは比較の結果が等しい要素を好きな順序で 配置してよいことになっています。レジスタアロケータはよく使う変数を優先するためにソートを行うことがありますし、 リンカはデータセクション内のシンボルをサイズでソートします。ソートアルゴリズムの影響を完全に排除するには、 使用する比較関数が二つの異なる要素を等しいと報告することが決してないようにする必要があります。実際のところ、 この不変条件をツールチェイン内のsortの利用すべてに課すのはあまりに負担が大きいことがわかったので、 代わりに私たちはGo 1.XのSortパッケージをブートストラップコンパイラに渡されるソースツリーにコピーする ようにしました。そうすることで、コンパイラはブートストラップツールチェインを使うときも、自分自身で ビルドされたときも、同じソートアルゴリズムを使うことになります。

もう一つコピーする必要があったパッケージがcompress/zlibです。リンカが圧縮された デバッグ情報を書き出す際、圧縮ライブラリへの最適化が正確な出力を変えてしまう可能性があるからです。時間の 経過とともに、私たちは他のパッケージもこのリストに追加してきました。 このアプローチには、Go 1.Xのコンパイラがこれらのパッケージに新しく追加されたAPIをすぐに使えるようになる という利点もありますが、その代償として、これらのパッケージは古いバージョンのGoでコンパイルできるように 書かれていなければなりません。

Go 1.20における再現可能性

Go 1.20での作業は、ツールチェイン管理と簡単な再現可能ビルドの両方に向けた準備として、 ツールチェインのビルドからさらに二つの関連する入力を取り除きました。

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

rebuild_cgo

したがって、ホストのCツールチェインは、ツールチェインに同梱される事前コンパイル済みのnet.aにとって 関連する入力になります。Go 1.20では、ツールチェインからnet.aを取り除くことでこれを修正することにしました。 つまり、Go 1.20はビルドキャッシュを種付けするための事前コンパイル済みパッケージの同梱をやめたのです。 現在では、あるプログラムがnetパッケージを初めて使用したときに、Goのツールチェインがローカルシステムの Cツールチェインを使ってそれをコンパイルし、その結果をキャッシュします。ツールチェインのビルドから 関連する入力を取り除き、ツールチェインのダウンロードサイズを小さくすることに加えて、事前コンパイル済み パッケージを同梱しないことは、ツールチェインのダウンロードの移植性も高めます。あるシステムであるCツールチェインを 使ってnetパッケージをビルドし、別のシステムで別のCツールチェインを使ってプログラムの他の部分をコンパイル した場合、一般に両者をリンクできる保証はありません。

そもそも私たちが事前コンパイル済みのnetパッケージを同梱していた理由の一つは、Cツールチェインが インストールされていないシステムでもnetパッケージを使うプログラムをビルドできるようにするためでした。 事前コンパイル済みのパッケージがない場合、そうしたシステムでは何が起きるのでしょうか。答えは オペレーティングシステムによって異なりますが、いずれの場合も、ホストのCツールチェインなしでも純粋なGoの プログラムをビルドするためにGoのツールチェインが引き続きうまく機能するよう手配しました。

  • macOSでは、実際のCコードを一切使わずに、cgoが使うのと同じ基盤となる仕組みを使ってnetパッケージを 書き直しました。これによりホストのCツールチェインの呼び出しを避けつつ、必要なシステムDLLを参照する バイナリを出力できます。このアプローチが可能なのは、すべてのMacに同じダイナミックライブラリが インストールされているからに他なりません。非cgo版のmacOSのnetパッケージがシステムDLLを使うように したことで、クロスコンパイルされたmacOSの実行可能ファイルもネットワークアクセスにシステムDLLを使う ようになり、長らく要望されていた機能が実現しました。
  • Windowsでは、netパッケージはすでにCコードなしで直接DLLを利用していたので、変更の必要はありませんでした。
  • Unix系のシステムでは、特定のDLLインターフェースをネットワークコードに仮定することはできませんが、 典型的なIPとDNSの構成を使うシステムでは純粋なGo版のパッケージで十分に動作します。また、Cツールチェインを インストールするのは、macOSや特にWindowsに比べてUnix系システムの方がずっと簡単です。私たちは goコマンドを変更し、システムにCツールチェインがインストールされているかどうかに基づいて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のブートストラッププロセスは次のようになっていました。

rebuild_linker1

ブートストラッププログラムであるcmd/distはローカルシステムのダイナミックリンカを調べ、その値を、リンカの 残りのソースコードと共にコンパイルされる新しいソースファイルに書き込みます。これは事実上、そのデフォルト値を リンカ自体にハードコードすることになります。そしてリンカがコンパイル済みのパッケージ群からプログラムを ビルドする際には、そのデフォルト値が使われます。この結果、Alpine上でビルドされたGoツールチェインは Ubuntu上でビルドされたものとは異なるものになります。つまりホストの構成がツールチェインのビルドにとって 関連する入力になっているのです。これは再現可能性の問題であると同時に、移植性の問題でもあります。 Alpine上でビルドされたGoツールチェインは、Ubuntu上で動くバイナリをビルドすることも、Ubuntu上で動作すること すらもできません。その逆もまた同様です。

Go 1.20では、リンカがビルド時にハードコードされたデフォルト値を持つのではなく、実行時にホストの構成を 参照するように変更することで、再現可能性の問題の解決に向けた一歩を踏み出しました。

rebuild_linker2

これによって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のバージョンでこうしたパスの大半はすでにスラッシュを使うように統一されていたのですが、 一箇所だけ不整合が紛れ込んでいて、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の署名鍵で署名して もらっていますが、他の人がその署名済みバイナリを再現できるようにするためにその秘密鍵を共有することは 当然できません。代わりに、私たちは二つのバイナリが署名を除いて同一かどうかを確認できる検証ツールを 作成しました。

OS固有のパッケージ作成ツール。 私たちはダウンロード可能なmacOSのPKGインストーラーを作成するために Xcodeのツールであるpkgbuildproductbuildを使い、ダウンロード可能なWindowsのMSIインストーラーを 作成するためにWiXを使っています。検証を行う人にこれらのツールとまったく同じバージョンを要求したくは なかったので、暗号署名鍵のときと同じアプローチを取り、パッケージの中身を調べてツールチェインのファイルが 期待通りのものであることを確認する検証ツールを作成しました。

Goのツールチェインを検証する

Goのツールチェインを一度だけ再現可能にすれば十分というわけではありません。私たちはそれらが再現可能な ままであることを確実にしたいですし、他の人々も簡単にそれらを再現できるようにしたいのです。

私たち自身が誠実であり続けるために、私たちは現在、信頼できるLinux/x86-64システムとWindows/x86-64システムの 両方ですべてのGoディストリビューションをビルドしています。アーキテクチャを除けば、この二つのシステムには ほとんど共通点がありません。この二つのシステムはビット単位で同一のアーカイブを生成しなければならず、 そうでなければリリースを進めません。

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

  • 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のパッケージをダウンロードして展開する必要があります。これらは ar(1)アーカイブであり、中にzstd圧縮されたtarアーカイブを含んでいます。

$ 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の二つに分割しています。これらを 再びまとめましょう。

$ 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は32ビットのx86向けにコンパイルする際にソフトウェア 浮動小数点の使用を強制するGO386=softfloatを使ってGoをビルドし、生成されたELFバイナリからシンボルテーブルを 削除(strip)しています。まず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に合わせてバイナリをstripしましょう。

$ 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)できます。

$ 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がそれを更新したことが原因のようです。おそらくUbuntuのパッケージ作成用スクリプトは、 既存の圧縮率を改善できない場合でもタイムスタンプを書き換えるツールを使ってpngを再圧縮しているのでしょう。
  • ブートストラップ時にビルドされるものの標準のアーカイブには含まれていないバイナリであるdistdistpackが、Ubuntuパッケージには含まれている。
  • Plan 9のビルドスクリプト(*.rc)は削除されているが、Windowsのビルドスクリプト(*.bat)は残っている。
  • mksyscall.plとここには示していない他の七つのPerlスクリプトは、ヘッダーが変更されている。

特に注目すべきは、私たちがツールチェインのバイナリをビット単位で再構築できたことです。それらはdiffの 結果にまったく現れていません。つまり、私たちはUbuntuのGoバイナリが上流のGoのソースコードに正確に 対応していることを証明したのです。

さらに良いことに、私たちはUbuntuのソフトウェアをまったく使わずにこれを証明しました。これらのコマンドは すべてMac上で実行されており、unzstdelfstripは短いGoのプログラムです。高度な攻撃者は、 パッケージ作成ツールを改変することでUbuntuパッケージに悪意のあるコードを挿入するかもしれません。もし そうしたなら、それらの悪意のあるツールを使ってクリーンなソースコードからUbuntuのGoパッケージを再現しても、 やはり悪意のあるパッケージとビット単位で同一のコピーが生成されてしまうでしょう。この種の攻撃は、そうした 再ビルドでは検出できず、ケン・トンプソンのコンパイラ攻撃に 似ています。Ubuntuのソフトウェアをまったく使わずにUbuntuのパッケージを検証することは、はるかに強力な 確認方法です。Goの完全に再現可能なビルドは、ホストのオペレーティングシステムやホストのアーキテクチャ、 ホストのCツールチェインといった意図しない詳細に依存しないため、このより強力な確認を可能にしているのです。

(歴史的な記録として余談ですが、ケン・トンプソンはかつて私に、自分の攻撃は実際には検出されたのだと 話してくれました。というのも、コンパイラのビルドが再現可能でなくなってしまったからです。それにはバグが あり、コンパイラに追加されたバックドアの中の文字列定数がうまく扱われておらず、コンパイラが自分自身を コンパイルするたびに1バイトずつNULバイトが増えていったのです。やがて誰かがビルドが再現可能でないことに 気づき、アセンブリにコンパイルすることで原因を突き止めようとしました。コンパイラのバックドアはアセンブリの 出力にはまったく再現されなかったため、その出力をアセンブルするとバックドアは取り除かれていたのです。)

結論

再現可能なビルドは、オープンソースのサプライチェーンを強化するための重要な手段です。SLSA のようなフレームワークは、来歴(provenance)と、信頼に関する判断を行うために使えるソフトウェアの 管理の連鎖(chain of custody)に焦点を当てています。再現可能なビルドは、その信頼が正しく置かれていることを 検証する方法を提供することで、そのアプローチを補完します。

完全な再現可能性(ソースファイルがビルドにとって唯一の関連する入力であること)は、コンパイラのツールチェインの ように自分自身をビルドするプログラムに対してのみ可能です。それは高い目標ですが、そのような価値があるのは、 まさに自分自身をホストするコンパイラのツールチェインが、そうでなければ検証するのが非常に困難だからです。 Goの完全な再現可能性は、パッケージ作成者がソースコードを変更していない限り、Go 1.21.0のLinux/x86-64 (お好きなシステムに置き換えてください)向けのあらゆる再パッケージ化が、たとえすべてソースからビルドされて いたとしても、まったく同じバイナリを配布しているはずだということを意味します。Ubuntu Linuxについては これが完全には成り立たないことを見てきましたが、それでも完全な再現可能性のおかげで、私たちはまったく 異なる、Ubuntuとは無関係のシステムを使ってUbuntuのパッケージ化を再現できるのです。

理想を言えば、バイナリ形式で配布されているすべてのオープンソースソフトウェアが、簡単に再現できるビルドを 持っているべきです。実際には、この記事で見てきたように、意図しない入力がビルドに紛れ込んでしまうのは とても簡単なことです。cgoを必要としないGoのプログラムであれば、再現可能なビルドは CGO_ENABLED=0 go build -trimpathでコンパイルするのと同じくらい単純です。cgoを無効にすることで ホストのCツールチェインという関連する入力が取り除かれ、-trimpathによって現在のディレクトリが 取り除かれます。もしあなたのプログラムがcgoを必要とするなら、go buildを実行する前に特定のホストの Cツールチェインのバージョンを用意する必要があります。たとえば特定の仮想マシンやコンテナイメージの中で ビルドを実行するといった方法があります。

Goを離れて考えると、Reproducible Buildsプロジェクトは、あらゆる オープンソースの再現可能性を向上させることを目指しており、自分自身のソフトウェアのビルドを再現可能に するための情報を得るための良い出発点となっています。

By Russ Cox