署名は通ったのに、その証跡は別のファイルの話だった
一言でいうと
サプライチェーンの証憑(SBOM・in-toto Statement・SLSA Provenance)は、署名が合っているかではなく、その文書が指すsubjectのdigestが、今手にしているファイルのフィンガープリントと同じかどうかを確認してはじめて、根拠になります。署名は文書が変わっていないという意味で、成果物と文書をつなぐのは、署名ではなくdigestです。
なぜ必要なのか
エアギャップ環境への持ち込み審査は、たいてい次のように回ります。媒体が1つ入ってきて、その中に、成果物と一緒に文書が数枚入っています。部品表(SBOM)、ビルド来歴(Provenance)、テスト合格確認書。審査担当者は、署名検証のコマンドを1回回して、「検証通過」の判子を押します。手順書にも、そう書かれています。
ところが、そのコマンドが実際に何を確認したのかを開いてみると、確認したのは、文書ファイル1つが署名されたあとで変わっていないという事実だけです。成果物は、そのコマンドに登場すらしません。そのため、署名が有効な証憑1枚を、前四半期にすでに承認を受けた旧バージョンに付けておいて、新しいバイナリを一緒に入れて送れば、署名だけを見る手順は、それをそのまま通過させます。文書を書き換えられる側は署名も付け直せるという事実が、署名を手順の最後の関門に置いた瞬間に、まるごと抜け落ちます。
証憑が増えるにつれて、この落とし穴は、かえって大きくなりました。文書が1枚のときは人が開いて見ますが、3枚、5枚になると、ツールが走査して、人は結果だけを見ます。ツールが何を確認して何を確認していないのか、誰ももう一度問いません。
どう動くのか
証憑は、3つの層に分けて見ると、混乱しません。
- 文書の層: この文書が署名されたあとで変わっていないか。署名検証が答えます。
- 紐付けの層: この文書が指す対象が、私が受け取ったファイルか。subjectのdigestが答えます。
- 主張の層: では、何を主張しているのか。predicateが答えます。
in-toto Statement v1は、4つのフィールドで、この構造をそのまま表現します。_typeは常にhttps://in-toto.io/Statement/v1で、subjectはこの証憑が適用される成果物の配列、predicateTypeは主張の種類を指すURI、predicateは主張の内容です。文書は、subjectのすべての要素がdigestを必ず持たなければならないと定め、成果物は名前ではもっぱらdigestでのみ照合すると、別に書いています。名前は区別のための付箋にすぎないので、書き換えても何も起きません。subjectの要素は、ResourceDescriptorという共通の形式で、uri・digest・contentのうち、少なくとも1つはなければなりません。
predicateTypeがhttps://slsa.dev/provenance/v1なら、predicateはSLSA Provenance v1です。ここには、buildDefinitionとrunDetailsの2つの塊が入ります。buildDefinitionには、ビルドの型を指すbuildType、外から入ってきた入力であるexternalParameters、プラットフォームが自分で入れたinternalParameters、ビルドが取り込んだものの一覧であるresolvedDependenciesがあります。runDetailsには、ビルドを回したプラットフォームを指すbuilder.id、そしてmetadataのinvocationId・startedOn・finishedOnがあります。仕様は、externalParametersは信用せず、下流で必ず検証するよう、internalParametersは、プラットフォームをすでに信頼しているものなので検証する必要がないと、分けて書いています。この区別が重要な理由は1つです。検証する場所と信じる場所を、文書があらかじめ分けておいたという意味だからです。
そして、Provenanceが語らないことがあります。SLSAレベルの文書のBuildトラックは、「成果物が期待どおりにビルドされたか」を検証できるようにすることが目的だと書いています。誰がどんな過程で作ったかは扱いますが、そのソースがレビューを経たか、依存関係に既知の脆弱性がないかは、Buildトラックの仕事ではありません。L1は、証憑が存在するだけでよく、偽造も簡単だと、文書自体が述べています。L2で専用インフラと署名が付き、L3で署名鍵が、ビルドの段階から手が届かないように隔離されます。ですから、「SLSAの証憑がある」という言葉は、レベルを抜けば、ほとんど何も言っていないのと同じです。
部品表は、CycloneDXかSPDXで届きます。CycloneDX 1.6のJSONは、bomFormatがCycloneDX、specVersionがバージョン番号、componentsがコンポーネントの配列で、コンポーネントごとにtypeとnameが必須です。フィンガープリントは、hashes配列に{"alg": "SHA-256", "content": "..."}の形で入ります。ここで、人がよく引っかかる場所がmetadata.componentです。それは、このSBOMが説明する対象そのものであって、コンポーネント一覧の要素ではありません。
最後に、エアギャップ環境がもう1つ奪います。Sigstore方式の流れは、署名を透明性ログに載せ、検証する側がそのログを照会して確認することを前提にしていますが、ネットワークの外に出る道がなければ、その照会は成り立ちません。そのため、持ち込み審査報告書には、「確認した」と「このネットワークでは確認する方法がない」を、必ず分けて書かなければなりません。
문서 층 서명 검증 → 이 문서가 안 바뀌었다
결속 층 subject.digest → 이 문서는 '이 파일' 얘기다 ← 여기가 비면 나머지가 다 헛것
주장 층 predicate → 그래서 이렇게 만들어졌다고 주장한다
現場での姿
最もよくある事故は、証憑のすり替えです。署名は有効なのに、subjectが別のファイルを指しています。旧バージョンを指すようにしておくと、「前回通過したあの証憑」なので、いっそう通りやすくなります。これを捕まえる方法は1つだけです。受け取ったファイルからフィンガープリントを計算し直して、subjectと突き合わせること。この1行が手順書になければ、残りの検証は飾りです。
2つ目は、SBOMを片方向にしか突き合わせないことです。文書を基準に走査すると、文書が語らないファイルが静かに入ってきて、ツリーを基準に走査すると、文書にだけある幽霊項目を見逃します。名前が両側にあるのに、フィンガープリントだけが違う場合は、また別の種類で、一覧だけを突き合わせる照合は、それをまるごと見逃します。
3つ目は、知らない述語に出会ったときです。predicateTypeが、私たちが読み方を知らないURIなら、その文書は、通過の根拠にはなれません。かといって、拒否の根拠でもありません。in-totoのパースルールは、ポリシーを単調に組むよう勧めています。「悪い証憑があれば止める」ではなく、「良い証憑があってはじめて通過」と書くということです。そう組んでおけば、証憑1枚が読めなくて通過が止まるだけで、読めなかったという理由で通過が開くことはありません。
4つ目は、公開鍵です。検証に使った公開鍵が、証憑と同じ媒体で来たなら、その媒体を手にした側が、文書と署名と公開鍵を一度にすり替えられます。署名検証の結果は、それでも通過です。そのため、審査記録には、署名が有効だという事実と、その鍵が発注元のものだという確認を、別に書きます。後者は、別の経路で受け取ったフィンガープリントがなければ、確認できません。
次のラボですること
発注元が送ってきた成果物と証憑3種をネットワークの中に再現して、8つのステップで突き合わせます。成果物のフィンガープリントとsubjectの突き合わせ、SBOMの双方向の突き合わせ、Provenanceで答えられる質問と答えられない質問の仕分け、署名検証1回、そして署名が有効なままsubjectだけが旧バージョンを指す証憑を自分で作って、「署名OK・突き合わせFAIL」を、2つの層で再現します。最後に、確認したことと確認できなかったことを分けて書いた、持ち込み審査報告書を残します。