knqyf263's blog

自分のためのメモとして残しておくためのブログ。

子育てと家族のキャリア

技術的なことばかり言ってたのに歳をとって急に子育てとか言い出す恒例のアレです。

子育てで勉強時間を取れず悩むソフトウェアエンジニアは多く御多分に洩れず自分もそうだったのですが、家族全体でキャリアを考えることで最近はそういった悩みも減ったので書いておきます。勉強時間を増やすためのハックとかではありませんし家族の性格にもよるので参考にはならないとは思いますが、こういう角度での記事をあまり見たことがなかったので一応残しておきます。

また書いている内容は我が家についてであって、他の家族がどうするべき、などの意見は一切含みません。

結論

最初に結論だけ書いておくと、妻が個人事業をすることでその事業の成長を見るのが楽しみになり、自分が家事育児に追われている間も妻が働ければ家族トータルでは成長できているなと思えて自分単体の成長では悩まなくなりました。

背景

現在イスラエルに住んでおり子供を1人育てているのですが、親は日本にいるため気軽に頼ることもできず夫婦2人で育てています。海外かつ日本人の少ない国ということもあり、日本とは勝手が違い普通に子育てするよりかなり多く時間を取られている実感があります。日本のように子育て便利グッズがなかったり、こちらで国民IDを持っていないので病院の予約も手間だったり、などなど。

そして他の多くのブログでも語られるように勉強時間が減り苦しむわけです。中には以下のような猛者もいるわけですが、超人の例を見ても仕方ないので一旦忘れます。

k0kubun.hatenablog.com

パートナーが専業主夫・主婦で全てやってくれる家庭もあるのかもしれませんが、一般的には子育てをどちらか一方が全面的に負担するのはかなり大変で、持続可能な子育てのためには夫婦で分担する必要があると考えています。何とか回っているように見えても「旦那が育児を全くやってくれない」などとSNS上で愚痴をこぼす人も散見されます。

うちの場合はお互い育児がやりたくないわけではなく、子供との時間は増やしたいと考えていました。子供の小さい時期というのは貴重で可能ならずっと遊んでいたいけれど、仕事もしないと生きていけないというジレンマです。もっと子供と遊ぶ時間が欲しい、でも仕事や勉強をする時間も欲しい、というよくある話です。

やったこと

上記の前提でやったことを書いていきます。

妻の職探し

イスラエルへの引っ越しにあたり妻は仕事を辞めることになったのですが、我が家では共働き必須だったので仕事を探すことにしました。理由としてはまず上に書いたように、我が家ではどちらかが家事育児を全て負担するのは難しそうだったという点。あとはそもそも妻が仕事をしたがっているという点。そして最後に経済的にお互い自立しておく方が関係性が悪化した時に別の道を歩む選択肢を取りやすいという点です。旦那が嫌になったものの経済的理由で離婚できない人などを見ることがあり、それはお互い辛そうなのでいつでも離れられるけど一緒にいるという関係を保つためにもお互い稼ぎがある方が個人的には良いと考えています。繰り返しになりますが、これはうちの家庭の話であって他の家庭がどうこうではありません。

短期的には自分が副業の時間を増やす方が実入りは多いのですが、長期的に見ると自分が怪我で働けなくなるリスクもありますし所得源泉を分散しておく方が良いと考えています。

ということで職探しをするのですが、旦那は雇われのサラリーマンで比較的安定しているので(現在は不況でレイオフが続いており実際には安定していないのですが当時は能天気だった)リスクを取れる仕事ができるという利点を活かしたらどうかという話になりました。正社員は決まった時間で働く必要があり大変ですが、個人事業主であれば裁量がありますし万が一当たればリターンは大きいです。その中でも色々と考えYouTubeを始めることにしました。収益化できるチャンネルですら上位10%しかいない狭き門ですが、妻は動画編集やサムネイル作成などが好きということもあって向いていそうだったのでとりあえず始めてみることにしました。

またイスラエルで当時妻は労働ビザを持っていなかったこともあり、収益がしばらくないのも好都合でした。条件を満たさないと収益化出来ないのでしばらくはタダ働きです。収益化できるぐらいチャンネルが伸びたら考えようということで問題を先送りにしていました。ちなみに今はこちらで労働ビザも手に入れて個人事業主を開業しています。

保育園の利用

これは家庭によって色々と考えがあると思いますが、うちでは1歳から保育園を利用することにしました。最初家で育ててみたところ、仕事も捗らず子供もテレビを見せるだけで遊んであげられない時間も多く、親子どちらにとっても中途半端になっていました。むしろ保育園に行っている間は全力で仕事をして、帰ってから全力で遊んであげるほうがメリハリついて良さそうということで保育園に通わせることを決めました。またせっかく海外にいるので異文化に触れたほうが子供の成長にも良さそうというのもあります。

そもそもイスラエルでは共働きが基本なので保育園やベビーシッターの利用が当たり前です。1歳未満でも保育園に入れる家庭が多いです。お互いキャリアがあるのでどちらかが専業するという考えはあまりないようです。女性にも必ず「仕事何してるの?」と聞きますし(専業主婦という考えがない)、銀行で自分の口座に紐付けて妻のクレジットカードを発行したいと言ったら「奥さんの給与用口座がないケースは少ないから特殊な設定が必要」と言われて驚きました。

保育園代は月20万円もするので生活は苦しくなりますが、こちらの保育園は朝食も出してくれて7:30-17:00まで見てくれるのでその点は良かったです(それでも高いですが)。金銭的には共働きで何とかカバーしつつ空いた時間の稼ぎが保育園代を上回れば良いなという楽観的な考えでした。

家事・育児の分担

妻の仕事はしばらく利益は出ないものの、うまくいけば場所時間を問わずリターンが得られる大きな可能性を秘めた仕事です。そういった仕事を頑張ってもらえるのは期待が持てて楽しいですしもっと多くの時間を使ってほしいと思ったので、家事・育児はなるべく均等に分担しました。最初は雰囲気で分担していたのですが、何となくお互い自分のほうがたくさんやっていると不平等を感じてしまいがちなのでルールを決めました。朝の犬の散歩する人と子供を保育園へ送る人は毎日交代する、一方が料理している間にもう一方が子供を風呂に入れる、などです。しばらく運用していたら自分のほうが料理の適性がありそうなので最近は自分が料理担当で固定されつつありますが、その分寝かしつけをやってもらったりなどやはり平等にやっています。在宅勤務だと昼夜作ったりするので週に12回ぐらいは料理してると思います。

気付いたこと

朝6時過ぎに起きても犬の散歩行って少し運動して朝食作って〜とやっているとあっという間に時間がなくなりますし、夜も子供迎えに行って夕飯作って寝かしつけて犬の散歩行って〜とやっていると一瞬で寝る時間になります。最初は寝かしつけの間にAudibleを活用したり色々と工夫してみたものの、無理に詰め込むより育児を楽しもうということでやめました。

そして自分で使える時間が減ったわけですが、妻のYouTubeが伸びていくのを見るのが楽しいことに気付きました。もちろんまだチャンネル登録者数1万人程度で自分が副業する方が大きく稼げる状態ではあるものの、毎日数値として成長が見えるのはわくわくします。ベンチマークとってパフォーマンス改善したり監視でメトリクスを見るのが好きな人には分かってもらえるんじゃないかと思います。サラリーマンとして必死に働いても昇給はたかが知れている中で、工夫次第で際限なく伸びていくビジネスを横で見ていると、自分ひとりの成長で悩んでいたのは視野が狭かったなと気付きました。今では妻はこちらの語学学校で働いたりもしていますが、これも将来自分で教室を開きたいのでカリキュラムを学びたいということで将来のビジネスのためにやっています。

このように家族全体での成長を考えるようになってからは自分の時間が減ることについて焦りを感じなくなり、むしろ積極的に子供の世話をするようになりました。自分が頑張ればその分パートナーの時間が増えるのでどう転んでも家族トータルではプラスになるという考えです。ただ最近では妻が朝子供を起こしに行くと親父を連れてこいと怒るぐらいになりましたし、二人で保育園に迎えに行っても自分の方に走ってくるので少し頑張りすぎた感もあります。昔はパソコンを触る時間をいかに長くするかという思考しか持ち合わせておらず、人を育てるということができるのか不安でしたが人間変わるものです。

お互いサラリーマンだとこうは行かなかったような気がしています。もちろん大きなプロジェクトを成功させたとか昇進したとか様々な節目はあるとは思いますが、短気な人間としては日々の成長が可視化されるのは大きいです。個人事業だと売上が毎月増えていくとかYouTubeだとチャンネル登録者数が増えていくとか、分かりやすいのが良いです。安定した給料が入るわけではないので売上が下がる月もあると思いますし当然大変ですが、片方がサラリーマンならそういうリスクも多少緩和されます。自分にある程度の稼ぎがあるおかげでしばらく無収入でも耐えられるというのが前提としてあることは理解していますが、個人事業をやる以上は初期に大小違えど痛みを伴うのは仕方ない気がしています。稼ぎのない時に毎月20万円を払うのは当然大変でした。

他に当たり前だけど改めて気付いたこととして、自営業だと休みを割と柔軟に取りやすく(お客さんありきの業種だとそうはいかなさそうですが)旅行に行きやすかったり、子供が風邪引いたときに家で面倒見たりできるというのもあります。

そして分野は違っても隣で頑張っている人を見ると刺激をもらえて良いのですが、そうすると今度は自分もやっぱり時間欲しいと思ったりしてその辺の若干の塩梅の悪さは今後の課題です。

まとめ

家族の形は様々なので他所についてどうこう言うつもりはなく、今のところ我が家はこの形でうまく回っているという話でした。今後やっぱり共働きサラリーマンこそ最高となるかもしれないですしまだまだ模索中です。

あと安定した時間の確保は難しいですが、短期的には睡眠を削って業務外で脆弱性の解析したりOSS作ったりとかはしてます。そして業務に必要な知識は業務中に身につけることにしているので2022年もKeyless Signingについて誰も読まないブログを書いたりWasm対応をガチャガチャやったりはできました。

curlでKeyless Signingする (6) - Trillian編

はじめに

前回のVerify編でRekorから返されたtlogの検証を行いましたが、そのうちTrillianに関連する部分はスキップしました。今回はそのTrillianに関しての記事です。Keyless Signing連載の最後です。

Rekorはドキュメントで以下のように書いています。

Rekor aims to provide an immutable, tamper-resistant ledger of metadata generated within a software project’s supply chain.

docs.sigstore.dev

このimmutable, tamper-resistant ledgerの部分はTrillianによって実現されています。まずこのTrilianの概要を見ていきます。

Trillianとは

Trillianは「改ざん不可能なログシステム」を提供できると謳っているOSSであり、概要は以下がわかりやすいです。

gigazine.net

Merkel Treeを用いておりCertificate Transparency (CT)で使うためにGoogleにより開発されたようです。CTで使われていることからも分かるようにログシステムの構築に使えます。Trillianでは追加のみ可能で改ざん耐性が高くなっています。Merkle Treeはビットコインなどでも利用されているため知っている人も多いと思います。

以下にドキュメントがありますが、短くまとめられていて簡単に読めるので興味がある人は一読をおすすめします。

transparency.dev

Trillianの良い点として「既存のシステムにも簡単に導入できる点」「スケール可能である点」「オープンソースである点」、そして「Googleによって開発されている点」を挙げています。自分で「Googleによって開発されている点」が良いと言うのはなかなか凄いですね。くりぃむしちゅー上田の「だって俺だぜ」を彷彿とさせます。最近だとStadiaの件もあり、いきなりメンテナンスやめちゃうのかなとむしろ不安になりそうではありますが、何にせよRekorではこのTrillianをバックエンドとして採用しています。

Verifiable Data Structures

Trillianにおいてデータをどのように検証するのかについてドキュメントで説明されています。

transparency.dev

すべてのログレコードはMerkle Treeのリーフとして追加され、ハッシュ値が計算されます。以下の図だと①-④それぞれのハッシュ値がA, B, D, Fとなっています。

そしてAとBを使って新しくハッシュ値を計算しCを作ります。同様にDとFからGを作ります。同様にして2つのハッシュ値をまとめ上げていき、最終的にTree head hashであるHが作られます。

説明しておいてなんですが単なるMerkle Treeです。知らない人はググれば山ほど出てくるので見てみてください。かくいう自分も忘れてて勉強し直したりしたので記事内でも「正しいか不安」みたいなことを何度も言っています。有識者のフィードバックを得たいというのも記事を公開する目的の一つなので、何か不正確な記述があれば教えていただけると助かります。

Tree head hash

リーフの値が改ざんされればTree head hash(上の図のH)の値が変わるため検知可能です。Tree head hashは他にもTree head, Root hash, Merkle tree hashなどと呼ばれたりもします。

Signed tree head

ですがこのTree head hashが改ざんされたら元も子もないです。リーフの値を改ざんし、Tree head hashを再計算してその値をセットすれば検証を不正に通すことが可能なためです。そこでTrillianではこのTree head hashに秘密鍵で署名します。これをSigned tree hash (STH) と呼びます。

ドキュメントで以下のように書かれているように、クライアントはまずこのSTHをTrillianの公開鍵で検証する必要があります。それまでこのSTHは信頼するべきではないです。

Before trusting a tree head hash from Trillian, clients verify it using a public key that's typically published separately, depending on the application.

この検証によって信頼できるTree head hashを得ることが出来て、次で説明するInclusion proofの計算が意味のあるものになります。というのが自分の理解ですが、Cosignでは現状STHの検証を行っていません。RekorのレスポンスにSTHが含まれたのも最近です。

github.com

自分の理解が正しければこれはセキュリティ上よろしくないのではないかと思っています。誰か詳しい人がいたら教えて下さい。

Inclusion proof

クライアントはログレコードを入手後、Trillianにinclusion proofを要求する必要があります。ドキュメントにある以下の例を見てみます。

②のレコードを得たときのinclusion proofの計算は以下です。

  1. ②のハッシュを計算しBを得る
  2. Tree head hashを計算するために必要なAとDを要求する
  3. AとBからCを計算する
  4. CとDからEを計算する
  5. Eと別途得たtree head hashを比較する

この検証を通れば②は確かにEをheadとする木に含まれるログレコードであることが分かります。これまた普通のMerkle Treeという感じなので特に難しくはないと思います。ブロックチェーンSPV(Simplified Payment Verification)もほぼ同じと理解していますが、あまり詳しいわけではないので間違っていたらすみません。

TrillianのドキュメントにはFull auditなどの説明もありますが、Cosignで行われる検証を理解するにはここまでの概要を知っておけば大丈夫です。

検証方法

以下ではKeyless Signingで行われる検証を見ていきます。

Inclusion proof

前回の記事 で見たRekorのレスポンスに inclusionProof というJSONフィールドが実は含まれていました。

$ jq -r '.["24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c"].verification.inclusionProof' tlog.json
{
  "checkpoint": "rekor.sigstore.dev - 2605736670972794746\n581074\nBriWiM7dZr7prBLkpV+jMbqK0pzRvewwGFZfT7aCuIo=\nTimestamp: 1665306326592902697\n\n— rekor.sigstore.dev wNI9ajBFAiAq5Q68+U4+c7f+aIFC7WKsMRcYLHifitu0qLtjKQfCxQIhAPBFWBiz3nzlTazlVeOnM8JXrVwhykS0L1mXCeZ8qn09\n",
  "hashes": [
    "cc1a15893df16a2d596e03a42ac6ce6b98cf8cf86be3910bdd36223833689a85",
    "afec120963813d296b5820c20f899600540788dff18dc924f4dd557e04bdde45",
    "608be1e81cb15ac09f18eec8bbe0b37dd26fac8dab63883930a52e072459febd",
    "78049d975ede977b9e88b2d634edcedfb81f29b4e03dee144e30a52588cfba61",
    "3ea8b7e6f1db53f92f5079b9d08adc2edeb1be59d3f98b4f3c64c42e3c5669f8",
    "b2c166358bb5b4463aa261b199eb24678427c3dc59fe6fc2366bebf4d9b47bdd",
    "32004cfb7313c04b7ba472f660d4be9d5b0833014d53db150e1f77dc8159cdca",
    "b09bd5323de0eb0c83886b9127f091b712b555e3c67bcea80b5cc3f82b45c32a",
    "b0e41deda57b9e11b5bb5027004e34c7b22e0f36d1a9fabe4cbbb1019fb8d19c",
    "7799f078ae51c03583b31b0d6db843451d215f39b2d1adc14fc27abf4d18de98",
    "1d5eef2fc091b35fa481948f99fd66fcad99ac6ec8935073e093b9bbc238ed59",
    "26e30b1796943964266f3d0ebe8bb07fb2c4643b4b484aa491e2fd0141b2719b",
    "19714fb6b7db3e6f6d2036fac7667e78b7faf36b3a6dcddac6d04085df49fd06",
    "dbf30f96aa4c47533bb57d4bb3e9c8d5dc52c36a66fe3691968bbb858cdb9435",
    "e4560d2e44c7afdfafb28772f57fc932ab0c7fe5fee196bdd569f895e4227f61"
  ],
  "logIndex": 580670,
  "rootHash": "06b89688cedd66bee9ac12e4a55fa331ba8ad29cd1bdec3018565f4fb682b88a",
  "treeSize": 581074
}

hashes が上で説明したtree head hashを計算するために必要なinclusion proofです。そしてtree head hashは rootHash として含まれています。今回は 06b89688cedd66bee9ac12e4a55fa331ba8ad29cd1bdec3018565f4fb682b88a になっています。統一するために以下ではroot hashと呼ぶことにします。

ハッシュ計算

TrillianはCTに使われていることから、CTについての仕様を定めたRFC 6962に沿って実装がされています。この中にMerkle Treeのリーフとノードのハッシュ値の計算方法が書かれています。ログレコードが4つの場合の木を書きました。このA-Dがリーフ、hA-hDがリーフハッシュ、hAB, hCD, hABCDがノードです。

RFCは以下です。

www.rfc-editor.org

Leaf hash

リーフハッシュの計算は以下で定義されています。

MTH({d(0)}) = SHA-256(0x00 || d(0))

要は元のデータの先頭に 0x00 をつけてハッシュ計算すれば良いというだけです。前回の記事で 0x00 をつけて計算したのはこれが理由です。

Node

node hashという表現はRFC内に見つけられなかったのでノードがハッシュ値のことを指すと思うのですが、the hash calculations for leaves and nodesのような表現もあったりして少し怪しいです。

ノードのハッシュ値の計算方法は以下です。kはn未満の最も大きい2のべき乗です。つまり k < n <= 2k を満たします。nが8だったらkは4ということになります。

MTH(D[n]) = SHA-256(0x01 || MTH(D[0:k]) || MTH(D[k:n]))

難しく見えますが、先頭に 0x01 をつけた上で左側の部分木のroot hashと右側の部分木のroot hashを結合してハッシュ計算しているだけです。つまり上の例で言えば 0x01, hA, hB の順番にバイト列を並べてSHA256でハッシュします。シンプルです。

logIndex

では具体的な実装方法について考えてみます。基本はinclusion proofに含まれるハッシュ値とくっつけてハッシュ計算していけばよいだけですが、inclusion proofを左側に置くのか右側に置くのかは重要です。

リーフが8つの場合で考えてみます。

このうちFのレコードについて考えてみます。root hashを計算するまでの手順は以下です。

  1. Leaf hashを計算する( hF
  2. hE を左に繋げてnodeのハッシュ計算をする( hEF
  3. hGH を右に繋げてnodeのハッシュ計算をする( hEFGH
  4. hABCD を左に繋げてnodeのハッシュ計算をする( hABCDEFGH

これでroot hashを得ることが出来ます。inclusion proofには hE, hGH, hABCD が含まれます。見れば分かる通り、最初はinclusion proofを左に繋げ、次は右、最後は左、という順番になっています。繋げる方向を間違うと当然正しいroot hashは得られません。これをどうやって得るかという話になりますが、これはindexのビットを見れば分かります。indexですが、Aを0としてHを7とすればFは5になります。5を2進数で表すと 101 になります。これはtreeのrootからどちらに進めばよいかを表します。最初は1なので右、次は0なので左、最後は1なので右になります。計算順序的には下からなので最下位ビットから見ていけば良いです。プログラムに落とし込むなら1ビットずつシフトして1とANDを取れば左右が分かります。F(index=5)の 101 を縦に書いた場合の図を載せておきます。

各ビットの値と左右が一致しているのが分かります。二進数と二分木の対応を考えたらそれはそうという感じではありますが、最初 logIndex をビットシフトするプログラムを見たときは少しの間「何で??」となったので勉強不足で恥ずかしい限りです。大学の方角に足を向けて寝られません。

完全二分木でない場合

上の方法でリーフからroot hashの計算方法が分かったと思いきや実は不十分です。木が完全二分木でない場合があるためです。

概要

ここから先の内容はソースコードから読み取った自分の理解をただ書き記したものなので間違いがある可能性があります。そして若干複雑ですしうまく解説できる気もしません。多分ほとんどの人が読む必要のないパートです。

まずリーフが7の場合を見てみます。ここで木の各階層のことをレベルと呼ぶことにします。Merkle Treeの説明でそう呼ばれていたので倣っています。リーフのハッシュ値はレベル0です。

図を見ると分かるように、Gは結合する相手がいないのでそのまま hG が持ち上がり、 hEF と結合されます。Gのindexは6なので二進数では 110 ですが結合は2回だけですし最下位ビットの0を見て右と結合すると判断してしまうと誤りです。そのため、indexを見て結合する方向を決めるべきレベルと、右側の部分木が存在しないために常に左側と結合するべき(または結合が不要な)レベルを区別する必要があります。前者を inner 、後者を outer と呼ぶことにします。 outer は勝手な造語です。

一旦リーフが8の場合を考え直してみます。このときGに着目すると、Level 1までは右側の部分木が存在し得るので左右の判定が必要ですが、Level 2以降は常に左側の部分木しかないため左右の判定をせずに結合可能です。

つまりGに着目すると inner = 1, outer = 2 になるということです。Fに着目すれば inner = 2, outer = 1 になります。下の図ではリーフハッシュの計算はもう省いていますが基本は上と同じです。

改めて言うと、 inner は右側に部分木があるレベル帯を指します。FはLevel 0とLevel 1の時点ではどちらも右側に部分木が存在しています( hGH を頭とする部分木)。そのため2つのレベルで右側に部分木があるということで inner = 2 となっています。このとき、結合する方向は関係ないです。 hFhE を左側に結合していますが、 inner の計算には影響しません。

リーフが8の場合は完全二分木だったので、 inner などややこしいことを言わずとも計算可能でした。そうではないリーフが7の場合に戻り、再びGに注目します。Hは存在しませんがわかりやすさのために点線で書いています。

このとき、 hG の右側に部分木は存在しないため inner = 0 になります。逆に outer は3となります。この inner のレベルまでは上のindexをビットシフトしていく方法で左右の判定をして結合し、 outer のレベルでは左側と結合・もしくは結合しない、という処理を行うことで完全二分木ではない場合にも対応可能になります。

outer における

  • 左側と結合する
  • 結合しない

の判定ですが、これは簡単で同様にindexのビットを見ればよいです。ビットが立っている場合は左側と結合、立っていない場合は結合不要になります。結合が不要な場合はそもそも結合相手のハッシュ値がinclusion proofに含まれないので、もっと簡単にビットが立っている回数分だけ左側と結合すれば良いということになります。

Gのindexは6で 110 なので、outerレベルにおいて立っているビットは2つです(上の図の緑のところ)。つまり2回左側と結合すればよいです( hEFhABCD との結合)。0の部分は結合不要です( hG はそのまま hG )。途中から面倒になって結合と言っていましたが、結合したあともちろんハッシュ計算が必要です。この常に左側と結合する回数のことを border と呼ぶことにします。これはソースコード上の変数名から借用しているのですが、 border という名前的にもう少し違うイメージなのかもしれません。自分ではうまく視覚化出来なかったので一旦これで勘弁してください。

ここまでで innerborder さえ求まればroot hashが計算できるようになりました。では inner の求め方を考えます。先ほど説明したように右側に部分木が存在するかどうか、なので木における一番右側のリーフのパスに着目すれば求められそうです。言葉だと分からないと思うので、図を見てみます。リーフが6の木を考えます。このとき、右端のリーフはFになります。これをrootから辿ると右→左→右になります。

ではEに着目します。Eをrootから辿ると右→左→左になります。このとき、図ではLevel 1→0の時点でFと分離します。このFとパスが重ならなくなった地点までのレベルが inner になります。この図におけるEの inner は1ということになります。右端のリーフであるFのパスと分離してしまったので、Level 1以下はEのパスより右側に部分木が存在します。

同じ木でDに着目してみます。図を見れば分かるようにLevel 3の時点でいきなり袂を分かっています。つまりDのパスにおいては常に右側に部分木が存在するため inner = 3 になります。

こうして inner を求めることができれば、 inner より上のレベルの中で立っているビットを数えれば border は簡単に求まります。

計算方法

上では図での概要理解に努めていましたが、ここでは実際のプログラムを見つつ計算方法を確認します。Merkle Tree用のライブラリが提供されているのでそちらを参照しています。

merkle/verify.go at 8e426287c6b7195f0a95f3b4fb1c92aa6a619ff0 · transparency-dev/merkle · GitHub

まず inner を計算します。

func innerProofSize(index, size uint64) int {
    return bits.Len64(index ^ (size - 1))
}

indexsize-1 でXORをとってビットの長さを計算しています。ここについてコメントで以下のように説明があります。

The splitting point between them is where paths to leaves |index| and |size-1| diverge.

これは上で説明したとおりです。 size - 1 は一番右側のリーフになるので、 indexsize - 1 のXORを取り最上位のビットを探すことで一番最初にこの2つのパスが分かれたレベルを探しています。

例えばリーフが6の例でEに着目すると、 size - 1 が5になってEのindexである4とXORを取ると 001 になります。最初の二回は同じパスで最後の一回が異なるというのを先程図で確認しました。001 に対して bits.Len64 は1を返します。 bits.Len64 の説明は以下です。

Len64 returns the minimum number of bits required to represent x; the result is 0 for x == 0.

サンプルでは以下のようになっています。

Len64(0000000000000000000000000000000000000000000000000000000000001000) = 4

この数値を表すための最小の表現は 1000 なので長さは4ということです。言い換えれば最初に現れる1を探していると言えると思います。そしてしつこいですが言い換えると最初にパスが分離するレベルを探しています。

次に border を計算します。

border := bits.OnesCount64(index >> uint(inner))

これはもう特に難しくないと思います。indexを inner 分ビットシフトすると outer が得られます。この outer のビットの中の1の数をカウントしています。上図の例で言えば E (=100) から inner (=1) ビットシフトすると 10 になります。 bits.OnesCount64 はポップカウントでビットの立っている数を返すため、この例では1が返ります。実際に outer における結合回数は上の図で見たとおり一度だけです。

プログラム的には最初 inner のレベルにおいては左右の判断が必要だが、そこを抜けて outer のレベルでは border の回数分だけ常に左と結合するだけで良い、ということです。

コードは以下です。

merkle/verify.go at 8e426287c6b7195f0a95f3b4fb1c92aa6a619ff0 · transparency-dev/merkle · GitHub

res := chainInner(hasher, leafHash, proof[:inner], index)
res = chainBorderRight(hasher, res, proof[inner:])

受け取ったinclusion proofのうち、 proof[:inner]chainInner を呼び出し proof[inner:]chainBorderRight を呼び出しています。名前やコメントからしても inner より上のレベルに行った場合は常に結合相手に対して右側にいる(右の部分木はない)と仮定して結合しているのが分かると思います。

// chainBorderRight chains proof hashes along tree borders. This differs from
// inner chaining because |proof| contains only left-side subtree hashes.
func chainBorderRight(hasher merkle.LogHasher, seed []byte, proof [][]byte) []byte {
    for _, h := range proof {
        seed = hasher.HashChildren(h, seed)
    }
    return seed
}

ソースコードを見ても proof (=h) は常に左側に置いて結合しています。

なるべく図を使って説明してきましたが、自分もふわっとした理解なせいもあって意味不明だったかもしれません。以上で概要の説明は終わりです。

手動で試す

Signed tree head (STH)

上でも述べましたが、CosignではこのSTH署名検証は現在(v1.13.0時点)行われていません。しかし以下のinclusion proofの計算は行っており、うーん?????となっています。root hashが信頼できないのにinclusion proofの計算する意味あるのか?!という気持ちです。

Inclusion proof

まずはリーフハッシュの計算を行います。これは実は前回の記事でも行いましたが、もう一度行っておきます。JSONの形を再度確認します。

$ jq -r '.["24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c"]' tlog.json
{
  "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI2OTM5YmQxZmI0Y2YxZTgxMGVhZmNjYmY4YzY5OTI3N2NhYWU3MzY0NGNhNTlkYjFiMDhkZGE0NGY1NTg3ZjA1In19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FWUNJUUNpWXZqc3BwOTVYZjNaZ3FJNTAzcTdlVUZzTjdmdVFLU3dXNFcwdHF0VERBSWhBUEJQUEk4SGREamkwUzU5U05RR1ZldUZuN0JoQTk2NFEzWi96enQ5d0lCaCIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTnZWRU5EUVdsbFowRjNTVUpCWjBsVlJ6STJRV0V3YVN0TVVVcDNUMEZWUTJwWWJYTTRlRzFzV0U1cmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEplRTFFUVRWTlJHY3hUbFJGZWxkb1kwNU5ha2w0VFVSQk5VMUVhM2RPVkVWNlYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZyTUVKdGFqRkJiek42UTBwbWRXVkdkMnd4YkRWNGJIWjNhMDkyZVRkTldVOUJObUVLYjFGR2FFMWxhRWd2ZFZCSmVURkJaMEl5TDFKdlVYUldVMWxEVFhwcFZrSlBSWGRJWWtwWEsyUlpkVWhWVGpWck56WlBRMEZWV1hkblowWkRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlY0YTJoa0NtUklhR2hUTTNKUmJqSnJZV2hwVUZobllXbDFZVWc0ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBsQldVUldVakJTUVZGSUwwSkNXWGRHU1VWVFlUSTFlR1ZYV1hsT2FrNUJXakl4YUdGWGQzVlpNamwwVFVOM1IwTnBjMGRCVVZGQ1p6YzRkd3BCVVVWRlNHMW9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWMySXlaSEJpYVRsMldWaFdNR0ZFUTBKcFoxbExTM2RaUWtKQlNGZGxVVWxGQ2tGblVqaENTRzlCWlVGQ01rRkJhR2RyZGtGdlZYWTViMUprU0ZKaGVXVkZia1ZXYmtkTGQxZFFZMDAwTUcwemJYWkRTVWRPYlRsNVFVRkJRbWMzZGpBS2JYTkpRVUZCVVVSQlJXTjNVbEZKWjBwaFMyWnJVM3BIZVZKek1HZzNNSFUyVVdSVVVubEpOVTlCVm1vMFV6TkZhRkpxWkVGRlRVRk1ZalJEU1ZGRGNBcHFWSGR0ZFU1MmVTOU9SR0k0Y1VSYVNUbEVkVXhRVUdkWFpsRnJOR05STUU4eVlscDBiRE0wVWtSQlMwSm5aM0ZvYTJwUFVGRlJSRUYzVG05QlJFSnNDa0ZxUlVGNGJGbEZja3RtTW5ORVZHUlBZek55YWtGVldFcEpUbkZhZUdwck1taFBVelJOVkVrMmRFNU9VbnB5ZVVKREswRkpiVTF2Tm5KQmJFMTRkMjBLWjAxV2VVRnFRamgyTWs1bmRIVXpkamd6VkRCSmF5dEJRVkpYWjNwS1VXTlVkWEYzYWpsd04yeGtSM1ZUYTJ3ME0waFVPVXRWU21aaFNYWktNalJsUXdwNlZXbzRaVmRWUFFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PSJ9fX19",
  "integratedTime": 1665305714,
  "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
  "logIndex": 4744101,
  "verification": {
    "inclusionProof": {
      "checkpoint": "rekor.sigstore.dev - 2605736670972794746\n581074\nBriWiM7dZr7prBLkpV+jMbqK0pzRvewwGFZfT7aCuIo=\nTimestamp: 1665306326592902697\n\n— rekor.sigstore.dev wNI9ajBFAiAq5Q68+U4+c7f+aIFC7WKsMRcYLHifitu0qLtjKQfCxQIhAPBFWBiz3nzlTazlVeOnM8JXrVwhykS0L1mXCeZ8qn09\n",
      "hashes": [
        "cc1a15893df16a2d596e03a42ac6ce6b98cf8cf86be3910bdd36223833689a85",
        "afec120963813d296b5820c20f899600540788dff18dc924f4dd557e04bdde45",
        "608be1e81cb15ac09f18eec8bbe0b37dd26fac8dab63883930a52e072459febd",
        "78049d975ede977b9e88b2d634edcedfb81f29b4e03dee144e30a52588cfba61",
        "3ea8b7e6f1db53f92f5079b9d08adc2edeb1be59d3f98b4f3c64c42e3c5669f8",
        "b2c166358bb5b4463aa261b199eb24678427c3dc59fe6fc2366bebf4d9b47bdd",
        "32004cfb7313c04b7ba472f660d4be9d5b0833014d53db150e1f77dc8159cdca",
        "b09bd5323de0eb0c83886b9127f091b712b555e3c67bcea80b5cc3f82b45c32a",
        "b0e41deda57b9e11b5bb5027004e34c7b22e0f36d1a9fabe4cbbb1019fb8d19c",
        "7799f078ae51c03583b31b0d6db843451d215f39b2d1adc14fc27abf4d18de98",
        "1d5eef2fc091b35fa481948f99fd66fcad99ac6ec8935073e093b9bbc238ed59",
        "26e30b1796943964266f3d0ebe8bb07fb2c4643b4b484aa491e2fd0141b2719b",
        "19714fb6b7db3e6f6d2036fac7667e78b7faf36b3a6dcddac6d04085df49fd06",
        "dbf30f96aa4c47533bb57d4bb3e9c8d5dc52c36a66fe3691968bbb858cdb9435",
        "e4560d2e44c7afdfafb28772f57fc932ab0c7fe5fee196bdd569f895e4227f61"
      ],
      "logIndex": 580670,
      "rootHash": "06b89688cedd66bee9ac12e4a55fa331ba8ad29cd1bdec3018565f4fb682b88a",
      "treeSize": 581074
    },
    "signedEntryTimestamp": "MEUCIHYBg6VLKjd5hnjRj34+mKp2KWObE4aCGFsPaWGsxlHgAiEA5JtI46YQE67uQBp1bbnUmO84fe90NKsusHJk1Vxle28="
  }
}

この bodyBase64デコードしたものの先頭に 0x00 をつけてハッシュすればよいのでした。

$ jq -r '.["24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c"].body' tlog.json | base64 -d | jq . > body.json
$ (echo -ne "\x00"; cat body.json) | sha256sum
aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c

ということでリーフハッシュを得ました。あとはinclusion proofと繋げてハッシュ計算をしていきます。inclusionProof の中身だけ proof.json などに書き出しておきます。

$ jq -r '.["24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c"].verification.inclusionProof' tlog.json > proof.json

まず inner を計算しますが、このためにはリーフの数が必要です。これは treeSize に入っています。今回は581074です。これから1引いて一番右端のリーフとし、logIndex とXORを取ります。logIndexinclusionProof 内にるのでこれを使います。ちなみに inclusionProof の外側にも logIndex がありますが、そちらではないです。自分はまさか2つあると思わずに最初外側の値を使っていて時間を無駄にしました(同じ名前にするのややこしいのでやめて欲しい)。今回の logIndex は580670です。

ではXORを取ります。

$ echo $((580670^(581074-1)))
495
$ echo -n 'obase=2;495' | bc
111101111

111101111 の長さは9なので inner = 9 であることが分かります。次に border ですが、 logIndex を9ビット右にシフトして立っているビットを数えれば良いです。

$ echo $((580670>>9))
1134
$ echo 'obase=2;1134' | bc | grep -o 1 | wc -l
6

ということで border = 6 でした。つまりLevel 9までは logIndex のビットを見て左右を判定し、それ以降は6回左側と結合すればよいです(自分が常に右側にいる)。ちなみに innerborder の合計数はハッシュ計算する回数と一致するため、 hashes の長さと一致しなくてはなりません。

$ jq -r ".hashes[]" proof.json | wc -l
15

ということで確かに inner = 9border = 6 の合計と一致しています。この検証もCosign内で行われています。

inclusion proofの1つ目のハッシュ値を見てみます。

$ jq -r ".hashes[0]" proof.json
cc1a15893df16a2d596e03a42ac6ce6b98cf8cf86be3910bdd36223833689a85

これを上で得たリーフハッシュに対して左右どちらに結合すれば良いのかを調べます。繰り返しになりますが、これは logIndex (=580670) のビットを見れば判定可能です。

$ echo 'obase=2;580670' | bc
10001101110000111110

logIndex10001101110000111110 なので最下位ビットが0であることから最初は木の左側にあることが分かります。つまり cc1a15893df16a2d596e03a42ac6ce6b98cf8cf86be3910bdd36223833689a85 を右側において結合すれば良さそうです。

$ echo -ne "\x01" > a
$ echo -n a55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c | xxd -r -p >> a
$ echo -n cc1a15893df16a2d596e03a42ac6ce6b98cf8cf86be3910bdd36223833689a85 | xxd -r -p >> a
$ sha256sum a
95ada66a7d8ca568794e9a2c364a02d07c6550bca5b229bd9670ae7ffaa781e4  a

ということでハッシュ値を得ました。あとは inner のLevel 9まで同様に繰り返し計算していくだけです。さすがに面倒だったのでシェルスクリプトを書いたのですが、それだったらもはや手動じゃないし普通にプログラム書けよって言われそうだったので「え!!手動でinclusion proofの計算を?!」「出来らぁっ!」ということで気合で手でやりました。自分は何と戦っているのでしょうか。

# Level 1: inclusion proofを左に置いて結合
$ (echo -ne "\x01"; \
echo -n afec120963813d296b5820c20f899600540788dff18dc924f4dd557e04bdde45 | xxd -r -p; \
echo -n 95ada66a7d8ca568794e9a2c364a02d07c6550bca5b229bd9670ae7ffaa781e4 | xxd -r -p) \
| sha256sum 
8e7c52b18e3d45f721b6f3e5acaa26d1c7fcabad46b6d2abe5f51bcd76470ce6  -

# Level 2: inclusion proofを左に置いて結合
$ (echo -ne "\x01"; \
echo -n 608be1e81cb15ac09f18eec8bbe0b37dd26fac8dab63883930a52e072459febd | xxd -r -p; \
echo -n 8e7c52b18e3d45f721b6f3e5acaa26d1c7fcabad46b6d2abe5f51bcd76470ce6 | xxd -r -p) \
| sha256sum
7f1f5967fc5b94dc023629c148b8e8d10e5c8272e48e529a0dd81442cb26777a  -

# Level 3: inclusion proofを左に置いて結合
$ (echo -ne "\x01"; \
echo -n 78049d975ede977b9e88b2d634edcedfb81f29b4e03dee144e30a52588cfba61 | xxd -r -p; \
echo -n 7f1f5967fc5b94dc023629c148b8e8d10e5c8272e48e529a0dd81442cb26777a | xxd -r -p) \
| sha256sum
884387bd3e198b5465904450c84c3007ba5a2f1d31a63161af09d61515663b62  -

# Level 4: inclusion proofを左に置いて結合
$ (echo -ne "\x01"; \
echo -n 3ea8b7e6f1db53f92f5079b9d08adc2edeb1be59d3f98b4f3c64c42e3c5669f8 | xxd -r -p; \
echo -n 884387bd3e198b5465904450c84c3007ba5a2f1d31a63161af09d61515663b62 | xxd -r -p) \
| sha256sum
380a408e0d035c4a24b16d2af3935b9a3f70593ee8bf53b0e8050ef8e6ef20d8  -

# Level 5: inclusion proofを左に置いて結合
$ (echo -ne "\x01"; \
echo -n b2c166358bb5b4463aa261b199eb24678427c3dc59fe6fc2366bebf4d9b47bdd | xxd -r -p; \
echo -n 380a408e0d035c4a24b16d2af3935b9a3f70593ee8bf53b0e8050ef8e6ef20d8 | xxd -r -p) \
| sha256sum
abc55c11d2749eaa8e65c3f97f7a338fd64a4998f4991fc946dd4896492b990d  -

# Level 6: inclusion proofを右に置いて結合
$ (echo -ne "\x01"; \
echo -n abc55c11d2749eaa8e65c3f97f7a338fd64a4998f4991fc946dd4896492b990d | xxd -r -p; \
echo -n 32004cfb7313c04b7ba472f660d4be9d5b0833014d53db150e1f77dc8159cdca | xxd -r -p) \
| sha256sum
e31926c9c9f415bc6f28b0af9e903ad506968abed079b9f318fe08bd70c8ba61  -

# Level 7: inclusion proofを右に置いて結合
$ (echo -ne "\x01"; \
echo -n e31926c9c9f415bc6f28b0af9e903ad506968abed079b9f318fe08bd70c8ba61 | xxd -r -p; \
echo -n b09bd5323de0eb0c83886b9127f091b712b555e3c67bcea80b5cc3f82b45c32a | xxd -r -p) \
| sha256sum
53fd8cdcb799bc77ddcb0b7009f7d9757ae2857096624fbfccfa9f0ba6f1c510  -

# Level 8: inclusion proofを右に置いて結合
$ (echo -ne "\x01"; \
echo -n 53fd8cdcb799bc77ddcb0b7009f7d9757ae2857096624fbfccfa9f0ba6f1c510 | xxd -r -p; \
echo -n b0e41deda57b9e11b5bb5027004e34c7b22e0f36d1a9fabe4cbbb1019fb8d19c | xxd -r -p) \
| sha256sum
a444a21f62222f79e53beb8951bb248fda73b22f9e244601aaa31cbbd1954a83  -

そして inner を抜けたらあとは border の回数分(今回は6回)、inclusion proofを左に置いて右から結合すればよいだけです。

# Level 9: inclusion proofを左に置いて結合
$ (echo -ne "\x01"; \
echo -n 7799f078ae51c03583b31b0d6db843451d215f39b2d1adc14fc27abf4d18de98 | xxd -r -p; \
echo -n a444a21f62222f79e53beb8951bb248fda73b22f9e244601aaa31cbbd1954a83 | xxd -r -p) \
| sha256sum
485a3c9bbdb8efd1a85daeb8b8539d75111257c3718c254ff5e1ced1b1ab2499  -

# Level 10: inclusion proofを左に置いて結合
$ (echo -ne "\x01"; \
echo -n 1d5eef2fc091b35fa481948f99fd66fcad99ac6ec8935073e093b9bbc238ed59 | xxd -r -p; \
echo -n 485a3c9bbdb8efd1a85daeb8b8539d75111257c3718c254ff5e1ced1b1ab2499 | xxd -r -p) \
| sha256sum
8cd88d2eba75f8b4925e3e767475a143495e93bc371c72d811a65a0bcda9e5f0  -

# Level 11: inclusion proofを左に置いて結合
(echo -ne "\x01"; \
echo -n 26e30b1796943964266f3d0ebe8bb07fb2c4643b4b484aa491e2fd0141b2719b | xxd -r -p; \
echo -n 8cd88d2eba75f8b4925e3e767475a143495e93bc371c72d811a65a0bcda9e5f0 | xxd -r -p) \
| sha256sum
1652f0d5a5786edf686fd9542aa894f61a0fe8316238ca6181f76d426d654171  -

# Level 12: inclusion proofを左に置いて結合
$ (echo -ne "\x01"; \
echo -n 19714fb6b7db3e6f6d2036fac7667e78b7faf36b3a6dcddac6d04085df49fd06 | xxd -r -p; \
echo -n 1652f0d5a5786edf686fd9542aa894f61a0fe8316238ca6181f76d426d654171 | xxd -r -p;) \
| sha256sum
97b9f5fab7df4afe0bbeec30bf4fd8321f2db603aa7e494b007c790bb8f1ea33  -

# Level 13: inclusion proofを左に置いて結合
$ (echo -ne "\x01"; \
echo -n dbf30f96aa4c47533bb57d4bb3e9c8d5dc52c36a66fe3691968bbb858cdb9435 | xxd -r -p; \
echo -n 97b9f5fab7df4afe0bbeec30bf4fd8321f2db603aa7e494b007c790bb8f1ea33 | xxd -r -p;) \
| sha256sum
a894dba1e82a44050d98c376ec7eb1856cd92f0a2dc669a1e6ec6a3b0b213db6  -

# Level 14: inclusion proofを左に置いて結合
$ (echo -ne "\x01"; \
echo -n a894dba1e82a44050d98c376ec7eb1856cd92f0a2dc669a1e6ec6a3b0b213db6 | xxd -r -p;) \
| sha256sum
06b89688cedd66bee9ac12e4a55fa331ba8ad29cd1bdec3018565f4fb682b88a  -

ということで 06b89688cedd66bee9ac12e4a55fa331ba8ad29cd1bdec3018565f4fb682b88a が得られました。これは確かに rootHash の値と一致しています。

これでようやくinclusion proofの計算ができました。

まとめ

何とかRekorのバックエンドとして使われているTrillianもそこそこ理解することが出来ました。これで長かったKeyless Signing連載も終了です。さすがに長過ぎて誰も読んでないと思うので、いつか概要版でも書くかもしれません。

しかしSTHを検証せずにinclusion proof計算するの意味がない気がしてならなくて夜も眠れません。

curlでKeyless Signingする (5) - Verify編

はじめに

Keyless Signing連載の最後の予定でしたが、もう一つ次にTrillianに関する記事を書いたので最後から二番目の記事です。

前回までで無事に署名を行うことができました。今回はそれを検証する側の処理を見ていきます。Cosign CLIを使った以下のコマンド相当になります。

COSIGN_EXPERIMENTAL=1 cosign verify-blob --signature foo.txt.sig foo.txt

この foo.txt.sigcosign sign-blob で生成されたものです。 foo.txt に対して署名が正しいかを検証しています。

ですが実はCosign v1.12.1の時点ではバグがあり常に検証が失敗します。他にも数多くバグがありCosignそのものはv1.0.0に到達はしていますが、Keylessの方はまだまだ安定には程遠い状態です。今回説明する挙動も少ししたら変わってしまう可能性があるので参考程度に考えてください。

現時点では verify-blob の主な処理は以下から始まります。

cosign/verify_blob.go at 63fe87501660595b7da9467078781e745272aa1b · sigstore/cosign · GitHub

全体像

最初に全体の流れを確認します。CLIフラグによって処理が変わってしまうため、上のように最低限必要なフラグ以外特に何も指定していない場合を想定しています。

  1. --signature で渡された署名をファイルから読み込む(上記の例では foo.txt.sig
  2. 渡されたファイルの中身を取り出す(上記の例では foo.txt
  3. Fulcioのルート証明書と中間証明書を取得する
  4. 署名対象のファイルのハッシュ値を使ってRekorでtlogを検索する
    1. UUIDの検証も行う
  5. tlog内の証明書を取り出して検証する
    1. Fulcioのルート証明書と中間証明書を使って証明書チェーンを検証する
    2. 指定されている場合はX.509拡張領域に含まれるメールアドレスやワークフローの情報の検証をする
    3. SCTが含まれる場合はSCTの検証を行う
  6. 渡された署名を検証する
    1. 上で取り出した証明書内の公開鍵を使って署名検証する
  7. tlogの検証を行う
    1. (root hashの署名検証)
    2. inclusion proofの検証
    3. SETの検証
    4. tlogの登録時刻が証明書の有効期限内であったことを確認

図にするまでもないですが、一応書いてみました。

一言で言ってしまえばRekorからtlogを取ってきて、その中に含まれる証明書を使って署名検証を行うというだけです。ただそのtlogは信頼できるのか?証明書はどこで誰が発行したのか?など様々な追加検証を必要とします。後半のtlog周りの検証がやや複雑なので最初に主な登場人物だけ図にしておきます。

この図は必要な要素を把握するための図であり、実際のJSONの構造などには厳密には対応していないので注意してください。

細かい部分は実際にやってみないと分からないと思うので早速やってみます。

手動で試す

上のフローのうち、最初の2つはファイルの中身を取り出すだけなのでスキップします。

Fulcioのルート証明書と中間証明書を取得する

以前の記事で説明したとおり、CosignではThe Update Framework (TUF)を使って証明書を落としてきます。今回は2つの証明書を適当にcurlで持ってきます。

$ curl -s -O https://sigstore-tuf-root.storage.googleapis.com/targets/fulcio_v1.crt.pem
$ curl -s -O https://sigstore-tuf-root.storage.googleapis.com/targets/fulcio_intermediate_v1.crt.pem

Rekorでtlogを検索する

まずはファイルのハッシュ値でtlogを検索して一覧を取得し、それぞれのtlogを個別に取得します。

検索API

まずCosignのリクエストを見ると以下のようになっていました。

> POST /api/v1/index/retrieve HTTP/1.1
> Host: rekor.sigstore.dev
> Accept: application/json
> Content-Length: 83
> Content-Type: application/json

{
    "hash": "sha256:167112362adb3b2041c11f7337437872f9d821e57e8c3edd68d87a1d0babd0f5"
}

/api/v1/index/retrieve にPOSTでハッシュ値を投げつければOKです。まず foo.txtハッシュ値を計算します。と言いたいところですが、自分が foo.txt の署名を登録しすぎてRekorのtlogが増えすぎて手元での検証に不向きになってしまいました。毎回使い捨ての鍵ペアを作って署名する関係上、同じファイルに対して全く同じフラグで cosign sign-blob を実行しても新しく署名が作られ新しいtlogがRekorに登録されます。

$ rekor-cli search --sha sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03
Found matching entries (listed by UUID):
24296fb24b8ad77a9e7a228364d0c667f0a0c17687bf48fa6bf005025cbe46b4af274e14cef2224a
362f8ecba72f4326907a8e725e8358c0ee2d050568fbc442472149d2d701a45828db074f941db393
362f8ecba72f4326aba9ebaacc89bf95412af8ad865351aadfb9ea554793fc8fb516ae926fa62f6b
37c85480b41ef39defbe46307406bcdeeafa20710ff9f47a1fc2af1fc4df5b44
4a51e6f4dbf1876e669a8c46135471c905e5f5b798b1f792a86fce853ef77ccc
9d58e3432062169fa43db2d22be96ebae349a3992b838ebf41907cc38aa40d8c
24296fb24b8ad77a8774957f3396300880ba34e722cdc50a4654da0f90a95ece464459ffcda8fafb
9a8d3150e86a1ef21e1b659b400ad326ccbdce325a68c2578a9c8082e09352ca
3172b0ad6ced7131d5dd5248398eba2e2da4226dac94e448733dbd4714526800
0de8bc75c2d6ad3bf27344dd8cd6c02306c886d40903b3e4ae9c5ca64cb11d77
...

このように同一ファイルに対する署名が大量に登録されています。微妙に長さの異なるIDが返ってきていますが、これは後述します。

今回は適当にランダムな文字列を書き込んで新しく test.txt を作ります。もし自分でも試してみたい人がいたらオリジナルなファイルでテストすることをおすすめします。

$ echo bp34Ja8wrx > test.txt 
$ COSIGN_EXPERIMENTAL=1 cosign sign-blob --output test.txt.sig test.txt

これで新しくRekorに test.txt のtlogが登録されました。署名は test.txt.sig に書き出されています。

では改めてファイルのハッシュ値を計算します。

$ openssl dgst -sha256 test.txt
SHA2-256(test.txt)= 6939bd1fb4cf1e810eafccbf8c699277caae73644ca59db1b08dda44f5587f05

この 6939bd1fb4cf1e810eafccbf8c699277caae73644ca59db1b08dda44f5587f05curlでPOSTします。これは検索APIなので、この test.txt に関連するtlogの一覧が返ってきます。

$ curl -H "Content-Type: application/json" \
     https://rekor.sigstore.dev/api/v1/index/retrieve \
     -d '{"hash": "6939bd1fb4cf1e810eafccbf8c699277caae73644ca59db1b08dda44f5587f05"}'
["24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c"]

今回は自分が直前に署名したものしか登録されていませんでした。24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c がそのtlogのEntry IDです。

取得API

Cosignのリクエストを覗くと以下のようになっています。

> GET /api/v1/log/entries/24296fb24b8ad77a46aa62553ea2b30f143f604e209da397af506fce5ef6c1f2c4772bc7e229a16d HTTP/1.1
> Host: rekor.sigstore.dev
> Accept: application/json

/api/v1/log/entries/ のあとに先程のtlogのEntry IDをつけてGETすれば良いようです。

$ curl https://rekor.sigstore.dev/api/v1/log/entries/24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c | jq . 
{
  "24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c": {
    "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI2OTM5YmQxZmI0Y2YxZTgxMGVhZmNjYmY4YzY5OTI3N2NhYWU3MzY0NGNhNTlkYjFiMDhkZGE0NGY1NTg3ZjA1In19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FWUNJUUNpWXZqc3BwOTVYZjNaZ3FJNTAzcTdlVUZzTjdmdVFLU3dXNFcwdHF0VERBSWhBUEJQUEk4SGREamkwUzU5U05RR1ZldUZuN0JoQTk2NFEzWi96enQ5d0lCaCIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTnZWRU5EUVdsbFowRjNTVUpCWjBsVlJ6STJRV0V3YVN0TVVVcDNUMEZWUTJwWWJYTTRlRzFzV0U1cmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEplRTFFUVRWTlJHY3hUbFJGZWxkb1kwNU5ha2w0VFVSQk5VMUVhM2RPVkVWNlYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZyTUVKdGFqRkJiek42UTBwbWRXVkdkMnd4YkRWNGJIWjNhMDkyZVRkTldVOUJObUVLYjFGR2FFMWxhRWd2ZFZCSmVURkJaMEl5TDFKdlVYUldVMWxEVFhwcFZrSlBSWGRJWWtwWEsyUlpkVWhWVGpWck56WlBRMEZWV1hkblowWkRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlY0YTJoa0NtUklhR2hUTTNKUmJqSnJZV2hwVUZobllXbDFZVWc0ZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBsQldVUldVakJTUVZGSUwwSkNXWGRHU1VWVFlUSTFlR1ZYV1hsT2FrNUJXakl4YUdGWGQzVlpNamwwVFVOM1IwTnBjMGRCVVZGQ1p6YzRkd3BCVVVWRlNHMW9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWMySXlaSEJpYVRsMldWaFdNR0ZFUTBKcFoxbExTM2RaUWtKQlNGZGxVVWxGQ2tGblVqaENTRzlCWlVGQ01rRkJhR2RyZGtGdlZYWTViMUprU0ZKaGVXVkZia1ZXYmtkTGQxZFFZMDAwTUcwemJYWkRTVWRPYlRsNVFVRkJRbWMzZGpBS2JYTkpRVUZCVVVSQlJXTjNVbEZKWjBwaFMyWnJVM3BIZVZKek1HZzNNSFUyVVdSVVVubEpOVTlCVm1vMFV6TkZhRkpxWkVGRlRVRk1ZalJEU1ZGRGNBcHFWSGR0ZFU1MmVTOU9SR0k0Y1VSYVNUbEVkVXhRVUdkWFpsRnJOR05STUU4eVlscDBiRE0wVWtSQlMwSm5aM0ZvYTJwUFVGRlJSRUYzVG05QlJFSnNDa0ZxUlVGNGJGbEZja3RtTW5ORVZHUlBZek55YWtGVldFcEpUbkZhZUdwck1taFBVelJOVkVrMmRFNU9VbnB5ZVVKREswRkpiVTF2Tm5KQmJFMTRkMjBLWjAxV2VVRnFRamgyTWs1bmRIVXpkamd6VkRCSmF5dEJRVkpYWjNwS1VXTlVkWEYzYWpsd04yeGtSM1ZUYTJ3ME0waFVPVXRWU21aaFNYWktNalJsUXdwNlZXbzRaVmRWUFFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PSJ9fX19",
    "integratedTime": 1665305714,
    "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
    "logIndex": 4744101,
    "verification": {
      "inclusionProof": {
        "checkpoint": "rekor.sigstore.dev - 2605736670972794746\n580960\nUtyGCosQlaXukz3rYq7U6d9ojA+6EDoxq5rLBPJFyCg=\nTimestamp: 1665306148019547985\n\n— rekor.sigstore.dev wNI9ajBGAiEApH3XjXMVXwKuDnniLusbKrZAu2BI2peoc7i7YEULbkgCIQCSiC5+7y2jHhWeVirWsE/hbIp5xsm03cdjnruKXEuRfg==\n",
        "hashes": [
          "cc1a15893df16a2d596e03a42ac6ce6b98cf8cf86be3910bdd36223833689a85",
          "afec120963813d296b5820c20f899600540788dff18dc924f4dd557e04bdde45",
          "608be1e81cb15ac09f18eec8bbe0b37dd26fac8dab63883930a52e072459febd",
          "78049d975ede977b9e88b2d634edcedfb81f29b4e03dee144e30a52588cfba61",
          "3ea8b7e6f1db53f92f5079b9d08adc2edeb1be59d3f98b4f3c64c42e3c5669f8",
          "b2c166358bb5b4463aa261b199eb24678427c3dc59fe6fc2366bebf4d9b47bdd",
          "32004cfb7313c04b7ba472f660d4be9d5b0833014d53db150e1f77dc8159cdca",
          "b09bd5323de0eb0c83886b9127f091b712b555e3c67bcea80b5cc3f82b45c32a",
          "f131d698d15761bd3c227905af6e32058f27c5be8abf982ab97e46a5270c0340",
          "7799f078ae51c03583b31b0d6db843451d215f39b2d1adc14fc27abf4d18de98",
          "1d5eef2fc091b35fa481948f99fd66fcad99ac6ec8935073e093b9bbc238ed59",
          "26e30b1796943964266f3d0ebe8bb07fb2c4643b4b484aa491e2fd0141b2719b",
          "19714fb6b7db3e6f6d2036fac7667e78b7faf36b3a6dcddac6d04085df49fd06",
          "dbf30f96aa4c47533bb57d4bb3e9c8d5dc52c36a66fe3691968bbb858cdb9435",
          "e4560d2e44c7afdfafb28772f57fc932ab0c7fe5fee196bdd569f895e4227f61"
        ],
        "logIndex": 580670,
        "rootHash": "52dc860a8b1095a5ee933deb62aed4e9df688c0fba103a31ab9acb04f245c828",
        "treeSize": 580960
      },
      "signedEntryTimestamp": "MEYCIQDhTk9NsobNVL1+u+swgGI/D+PQpoHds6j5twmCKBfSXgIhAJ6E17YBXb3ZXcWEq7GU3Wfsl6Z/gt71poSWe8E2nrvw"
    }
  }
}

このレスポンスはあとで繰り返し使うので一旦ファイル( tlog.json )に書き出します。

$ curl https://rekor.sigstore.dev/api/v1/log/entries/24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c | jq . > tlog.json

body の部分はBase64エンコードされていますが、デコードすると中に証明書や署名が含まれています。

$ jq -r '.["24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c"].body' tlog.json | base64 -d | jq .
{
  "apiVersion": "0.0.1",
  "kind": "hashedrekord",
  "spec": {
    "data": {
      "hash": {
        "algorithm": "sha256",
        "value": "6939bd1fb4cf1e810eafccbf8c699277caae73644ca59db1b08dda44f5587f05"
      }
    },
    "signature": {
      "content": "MEYCIQCiYvjspp95Xf3ZgqI503q7eUFsN7fuQKSwW4W0tqtTDAIhAPBPPI8HdDji0S59SNQGVeuFn7BhA964Q3Z/zzt9wIBh",
      "publicKey": {
        "content": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNvVENDQWllZ0F3SUJBZ0lVRzI2QWEwaStMUUp3T0FVQ2pYbXM4eG1sWE5rd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJeE1EQTVNRGcxTlRFeldoY05Nakl4TURBNU1Ea3dOVEV6V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVrMEJtajFBbzN6Q0pmdWVGd2wxbDV4bHZ3a092eTdNWU9BNmEKb1FGaE1laEgvdVBJeTFBZ0IyL1JvUXRWU1lDTXppVkJPRXdIYkpXK2RZdUhVTjVrNzZPQ0FVWXdnZ0ZDTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVV4a2hkCmRIaGhTM3JRbjJrYWhpUFhnYWl1YUg4d0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0lBWURWUjBSQVFIL0JCWXdGSUVTYTI1eGVXWXlOak5BWjIxaGFXd3VZMjl0TUN3R0Npc0dBUVFCZzc4dwpBUUVFSG1oMGRIQnpPaTh2WjJsMGFIVmlMbU52YlM5c2IyZHBiaTl2WVhWMGFEQ0JpZ1lLS3dZQkJBSFdlUUlFCkFnUjhCSG9BZUFCMkFBaGdrdkFvVXY5b1JkSFJheWVFbkVWbkdLd1dQY000MG0zbXZDSUdObTl5QUFBQmc3djAKbXNJQUFBUURBRWN3UlFJZ0phS2ZrU3pHeVJzMGg3MHU2UWRUUnlJNU9BVmo0UzNFaFJqZEFFTUFMYjRDSVFDcApqVHdtdU52eS9ORGI4cURaSTlEdUxQUGdXZlFrNGNRME8yYlp0bDM0UkRBS0JnZ3Foa2pPUFFRREF3Tm9BREJsCkFqRUF4bFlFcktmMnNEVGRPYzNyakFVWEpJTnFaeGprMmhPUzRNVEk2dE5OUnpyeUJDK0FJbU1vNnJBbE14d20KZ01WeUFqQjh2Mk5ndHUzdjgzVDBJaytBQVJXZ3pKUWNUdXF3ajlwN2xkR3VTa2w0M0hUOUtVSmZhSXZKMjRlQwp6VWo4ZVdVPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
      }
    }
  }
}

これはHashed rekordと呼ばれるフォーマットで、以下に記載があります。Rekorなのでrecordじゃなくてrekordです。

github.com

Hashed rekorには署名対象のデータ(今回は test.txt )のSHA256のハッシュ値と証明書や署名が含まれています。これを使って署名検証します。後で手動でやってみるので、このbodyをBase64デコードしたものもファイル( body.json )に書き出しておきます。

$ jq -r '.["24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c"].body' tlog.json | base64 -d | jq . > body.json

UUIDの検証

”UUIDの検証”が何を意味するかは上の概要からだと理解が難しかったと思います。以下で具体的に手動でやってみますが、先に図を載せておきます。body を特定の方法でハッシュ化した値がUUIDと一致することを確認します。

Rekorのレスポンスは 24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c がkeyとなりvaluebody などが入っていました。このkeyがEntry IDで、このEntry IDはTree IDとUUIDで構成されています。

Entry ID (80 bytes) = Tree ID (16 bytes) + UUID (64 bytes)

以前はEntry ID = UUIDになっていたのですが、少し前からTree IDが付与されるようになりました。その結果、古くに登録したtlogのIDは64バイトで新しく登録されたtlogのIDは80バイトになって混在しています。上で rekor-cli search を実行したときに長さの異なるIDが返ってきていたのはこのためです。

余談ですがこれが原因で破壊的変更が起こり、古いRekorクライアントが動かなくなりました。かなりバッサリといかれ、自分の開発しているOSSも突然全く動かなくなるなど影響がありました。Rekorのパブリックインスタンス (rekor.sigstore.dev) を使っていると突然動かなくなる事が多いので注意が必要です。

Entry IDからUUIDを取り出すのは後半64バイトを抜き出すだけなので簡単です。

cosign/tlog.go at b3b6ae25362dc2c92c78abf2370ba0342ee86b2f · sigstore/cosign · GitHub

ここではcutで取り出します。

$ echo -n "24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c" | cut -b 16-
aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c

この aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c がUUIDになります。これがbodyをBase64デコードしたもののハッシュ値と一致するかを検証します。

これらの処理は以下の verifyUUID で行われています。

cosign/tlog.go at b3b6ae25362dc2c92c78abf2370ba0342ee86b2f · sigstore/cosign · GitHub

先程保存しておいた tlog.json からbodyを取り出してハッシュ値を計算します。

$ jq -r '.["24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c"].body' tlog.json | base64 -d | sha256sum
90a6edfecb0b4da9581116d557df8ef803b57372dce3e11267ea9941f798ea07  -

しかし残念なことに値が異なります。上は aa55d から始まっていたのにこっちは 90a6ed で始まっています。

実はbodyを単にハッシュするだけではダメで、RFC 6962 (Certificate Transparency) のリーフのハッシュ計算方法に従う必要があります。細かい話は次の記事でTrillianについて説明するときに解説します。

datatracker.ietf.org

今のところはリーフのハッシュ値は先頭に 0x00 を入れる、ノードの場合は先頭に 0x01 を入れるということだけ知っておけばOKです。今回はリーフなので先頭に 0x00 を入れます。

以下で実装されています。

merkle/rfc6962.go at a76aeeada49756638c9176af8d3626faff7ccf3b · transparency-dev/merkle · GitHub

今回は適当にechoで入れます。

$ (echo -ne "\x00"; cat body.json) | sha256sum
aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c

ということでめでたく先程のUUIDと一致しました。

ただUUIDの検証を何故クライアントでやらなくてはいけないのかは正直不明です。自分にはRekorがサーバ側で担保するべきものに見え、クライアントで何のために検証するのかはよく分かっていないです。データ改ざんの場合はUUIDも改ざんされそうですし(それは実はTrillian側で検証可能だがここでは説明しません)、Rekorが壊れてデータに不整合が起きたときのため...?うーむ。

tlog内の証明書を取り出して検証する

証明書および含まれる公開鍵が正しく発行されたものであることを検証します。

先程の body.json から証明書( spec.signature.publicKey )を取り出します。Base64エンコードされているのでデコードもしてファイル( cert.pem )に書き出します。

$ jq -r .spec.signature.publicKey.content body.json | base64 -d > cert.pem

中を見てみます。

$ openssl x509 -in cert.pem -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            1b:6e:80:6b:48:be:2d:02:70:38:05:02:8d:79:ac:f3:19:a5:5c:d9
        Signature Algorithm: ecdsa-with-SHA384
        Issuer: O = sigstore.dev, CN = sigstore-intermediate
        Validity
            Not Before: Oct  9 08:55:13 2022 GMT
            Not After : Oct  9 09:05:13 2022 GMT
        Subject:
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub:
                    04:93:40:66:8f:50:28:df:30:89:7e:e7:85:c2:5d:
                    65:e7:19:6f:c2:43:af:cb:b3:18:38:0e:9a:a1:01:
                    61:31:e8:47:fe:e3:c8:cb:50:20:07:6f:d1:a1:0b:
                    55:49:80:8c:ce:25:41:38:4c:07:6c:95:be:75:8b:
                    87:50:de:64:ef
                ASN1 OID: prime256v1
                NIST CURVE: P-256
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature
            X509v3 Extended Key Usage:
                Code Signing
            X509v3 Subject Key Identifier:
                C6:48:5D:74:78:61:4B:7A:D0:9F:69:1A:86:23:D7:81:A8:AE:68:7F
            X509v3 Authority Key Identifier:
                DF:D3:E9:CF:56:24:11:96:F9:A8:D8:E9:28:55:A2:C6:2E:18:64:3F
            X509v3 Subject Alternative Name: critical
                email:knqyf263@gmail.com
            1.3.6.1.4.1.57264.1.1:
                https://github.com/login/oauth
            CT Precertificate SCTs:
                Signed Certificate Timestamp:
                    Version   : v1 (0x0)
                    Log ID    : 08:60:92:F0:28:52:FF:68:45:D1:D1:6B:27:84:9C:45:
                                67:18:AC:16:3D:C3:38:D2:6D:E6:BC:22:06:36:6F:72
                    Timestamp : Oct  9 08:55:13.346 2022 GMT
                    Extensions: none
                    Signature : ecdsa-with-SHA256
                                30:45:02:20:25:A2:9F:91:2C:C6:C9:1B:34:87:BD:2E:
                                E9:07:53:47:22:39:38:05:63:E1:2D:C4:85:18:DD:00:
                                43:00:2D:BE:02:21:00:A9:8D:3C:26:B8:DB:F2:FC:D0:
                                DB:F2:A0:D9:23:D0:EE:2C:F3:E0:59:F4:24:E1:C4:34:
                                3B:66:D9:B6:5D:F8:44
    Signature Algorithm: ecdsa-with-SHA384
    Signature Value:
        30:65:02:31:00:c6:56:04:ac:a7:f6:b0:34:dd:39:cd:eb:8c:
        05:17:24:83:6a:67:18:e4:da:13:92:e0:c4:c8:ea:d3:4d:47:
        3a:f2:04:2f:80:22:63:28:ea:b0:25:33:1c:26:80:c5:72:02:
        30:7c:bf:63:60:b6:ed:ef:f3:74:f4:22:4f:80:01:15:a0:cc:
        94:1c:4e:ea:b0:8f:da:7b:95:d1:ae:4a:49:78:dc:74:fd:29:
        42:5f:68:8b:c9:db:87:82:cd:48:fc:79:65

確かに自分のメールアドレスが含まれています。

証明書チェーンの検証

この証明書は前回確かめたように自己署名証明書でもアップロード可能です。つまりクライアント側でFulcioの証明書チェーンを確認する必要があります。

Fulcioのルート証明書と中間証明書は先ほど落としたものを使います。証明書の有効期限は切れているので -no_check_time を指定しています。

$ openssl verify -verbose -no_check_time -CAfile fulcio_v1.crt.pem -untrusted fulcio_intermediate_v1.crt.pem cert.pem
cert.pem: OK

ちなみにLibreSSLだと no_check_time を指定しなくても有効期限が切れている旨を表示した上で検証には成功します。

$ openssl version
LibreSSL 2.8.3
$ openssl verify -CAfile fulcio_v1.crt.pem -untrusted fulcio_intermediate_v1.crt.pem cert.pem
cert.pem:
error 10 at 0 depth lookup:certificate has expired
OK

ということでこの証明書がFulcioで発行されたものであることがわかりました。

メールアドレスやワークフロー情報の検証

これは先程opensslコマンドで確認したので今回は目視で良しとします。実際にはプログラムで一致することを確かめてください。OpenID ConnectのIDトークンによってX.509拡張には正しい値が含まれていることが保証されています。かつFulcioで発行された証明書であることも上で確認済みなので、悪意ある人がX.509拡張に好きな値を入れて証明書を作ることも出来ません。

ただ後述しますが、ここの検証はCosignではoptionalなCLIフラグになっています。そのためほとんどの人が多分指定しないですし紹介ブログなどでも指定されていません。個人的にはこれは致命的な問題だと思っています。

SCTの検証

Fulcioが仮に侵害されて不正な証明書が発行された場合でも、Certificate Transparency (CT) Logによって誰でも証明書の発行記録を監査可能です。侵害された場合はCTサーバに登録せずに証明書を発行される恐れもあるため、CTサーバに登録されたときに発行されるSigned Certificate Timestamp (SCT)を検証する必要がありますが、これは前回の記事で嫌というほどやったので省略します。

証明書の発行がCTサーバにログとして正しく登録されているということを保証するための検証です。

渡された署名を検証する

これはソフトウェア署名の根幹の部分です。配布されているソフトウェアが改ざんされたり差し替えられたときに検知できるようにするためのものです。

--signature test.txt.sig で渡された署名を検証します。公開鍵は上で検証した証明書に含まれているので取り出します。

$ openssl x509 -in cert.pem -pubkey -noout > pub.pem

あとはいつも通り検証するだけです。ただ test.txt.sigBase64エンコードされているのでデコードが必要です。

$ openssl dgst -sha256 -verify pub.pem --signature <(base64 -d test.txt.sig) test.txt
Verified OK

ということで署名の検証は完了です。ただこれで終わりではなくtlogの検証が必要です。

tlogの検証を行う

上でtlog内に含まれる証明書を使って署名検証を行いましたが、そもそもtlogは信頼できるのかということを確かめるための検証です。例えばRekorが侵害されてtlogを改ざんされた場合などへの対策になります。

root hash/inclusion proofの検証

Merkle Treeを知っている人はある程度予想のつく内容だと思いますが、Rekorで使われているTrillianの内部に大きく関係するので次の記事で説明します。ここでは割愛します。

SETの検証

SETの検証についても前回のRekor編で細かくやったので省略します。tlogが正しくRekorに登録されていることを保証するための検証です。

tlogの登録時刻が証明書の有効期限内であったことを確認

Rekorのレスポンス内に integratedTime が含まれているので、その値を証明書の有効期限と比較します。tlogの登録時刻が証明書の有効期限内であればOKです。

$ jq -r '.["24296fb24b8ad77aa55d79859da86ed47339e32d51ad2a8b0640a49652f5cecf0f7eba06d2228e6c"].integratedTime' tlog.json
1665305714

今回だと1665305714です。先程 openssl verify で証明書チェーンの確認をした際には -no_check_time で有効期限の検証をスキップしましたが、 -attime というCLIフラグで特定の時刻における有効性を確認できるようなのでこちらを使ってみます。

$ openssl verify -verbose -attime 1665305714 -CAfile fulcio_v1.crt.pem -untrusted fulcio_intermediate_v1.crt.pem cert.pem
cert.pem: OK

確かにtlogが登録された時刻において証明書は有効だったことがわかりました。証明書が仮に盗まれて不正な署名を作られたとしても、証明書はデフォルトでは10分しか有効期限がないためそれ以降に登録された署名を含むtlogは弾くことができます。ざっくり言えば証明書が作られてすぐにRekorに登録されたtlogならOKということです。

所感

以上で署名検証は終わりです。ここからはKeyless Signingに対する個人の感想です。

ユーザの負担

鍵の管理が不要になるのはソフトウェアメンテナにとっては間違いなく嬉しいですが、検証する側の負担は依然として高い気がしています。検証時に信頼する公開鍵を取得する必要はなくなりましたが、信頼する署名者の情報はどこからか得る必要があります。例えばこのソフトウェアの署名としてこのメールアドレスは信頼できる、などです。実際にはCI/CDなどでビルドすることが多いと思うのでメールアドレスではなくワークフローの情報が記録されることになると思いますが、いずれにせよ署名した人間・ワークフローのallowlistがソフトウェアごとに必要になります。ここは結局利用者の負担が大きいままであると感じています。

また、署名も今は利用者がどうにかどこかから取得する必要があります。以前は --signature フラグは不要でtlog内のものを使うようになっていたのですが、攻撃者が同じファイルに対する署名としてtlogを作っておくとCosignは作成日時の新しいtlogを取ってくるようになっていたので検証を失敗させることができました。これについては以前指摘したのですが、そのリスクは許容しますと言われ、のちにしれっと --signature が必須になっていました。

セキュリティ上の懸念

上述した署名者のアイデンティティの配布・管理はかなり重要だと思うのですが、この部分について語られていることは少ないです。何なら世の中のブログはどの署名者が信頼できるのかの検証を行わずに終えているものが多く、攻撃者が正当な手順を踏んで署名した場合でも通ってしまい巨大なセキュリティホールを生み出しているように見えます。例えば attacker@example.com のメールアドレスを持つ攻撃者が通常のKeyless Signingのフロー通りFulcioで証明書を発行し、署名をRekorに登録した場合にはtlog検証や署名検証は通ってしまいます。このような攻撃を弾けるのは以下のステップだけだと理解しています。

メールアドレスやワークフロー情報の検証

しかしこれは上述したようにoptionalですし指定されているケースを現時点では殆ど見かけていません。

そしてsigstoreもKeyless Signingで全てがハッピーになるように宣伝しており、面倒だけど重要な部分の啓蒙が不足しています。もちろん鍵の管理が不要になるのは素晴らしいことですし気軽に署名が行われるようになればソフトウェアサプライチェーンに間違いなく貢献すると思うのですが、sigstoreのやり方がSecure By Designじゃない感じがあって正直思想に共感できていない部分が多いです。

まとめ

最初Keyless Signingにおける cosign verify-blob の流れを見たときはやることの多さに少し驚いたかもしれませんが、一つ一つを見ていくと意外と大したことなかったと感じた人が多いかと思います。ここまで署名時の要素技術を一つずつ細かく見てきたので、検証はその逆をするだけですしすんなり入ってきたはずです(そうであって欲しい)。どのステップが何からの攻撃を防いでいるのか、というところまで考えると大変ですし自分もまだ理解しきれていないところがありますが、大枠を理解するには今回の内容で十分ではないかと思います。

curlでKeyless Signingする (4) - Rekor編

はじめに

手動でKeyless Signingしてみようという誰にも望まれていないシリーズの4本目です。

前回までの記事で手元で鍵ペアを作ってFulcionから証明書を発行するところまでを行いました。今回は実際にソフトウェアに署名を行い、その署名と証明書をRekorにアップロードするところまでをやってみます。ここまで終わればKeyless Signingは完了です。

Rekorは Software Supply Chain Transparency Log という説明から分かるように、ソフトウェアサプライチェーンのためのTransparency Logサーバです。

docs.sigstore.dev

前回の記事でCertificate Transparency (CT)について説明した際にCTログサーバが登場しましたが、Rekorはそれと近い役割を持っています。ただしRekorは証明書だけではなく署名なども保存しますし、attestationと呼ばれる署名付きメタデータも保存できます。そのため保存されるログはCertificate Transparency LogではなくTransparency Log (tlog)と呼ばれています。

Cosignで試す

まず普通にCosign CLIを使ってRekorにエントリを追加してみます。

$ echo hello > foo.txt
$ COSIGN_EXPERIMENTAL=1 cosign sign-blob foo.txt -d -y

Using payload from: foo.txt
Generating ephemeral keys...
Retrieving signed certificate...

        Note that there may be personally identifiable information associated with this signed artifact.
        This may include the email address associated with the account with which you authenticate.
        This information will be used for signing this artifact and will be stored in public transparency logs and cannot be removed later.
Your browser will now be opened to:
https://oauth2.sigstore.dev/auth/auth?access_type=online&client_id=sigstore&code_challenge=H13ag3T3yPmFwkMTSseirGjQ5tfKS2qikJ1AMRKK_v8&code_challenge_method=S256&nonce=2F7Fus8FmtjTIQDcqThsEFJcPc0&redirect_uri=http%3A%2F%2Flocalhost%3A61210%2Fauth%2Fcallback&response_type=code&scope=openid+email&state=2F7Fur6rgHNP72W6fTGrduNN4SF
Successfully verified SCT...
using ephemeral certificate:
-----BEGIN CERTIFICATE-----
MIICoTCCAiegAwIBAgIUJxUJH9K0n8Ny0QZPogrFANOE06MwCgYIKoZIzj0EAwMw
NzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRl
cm1lZGlhdGUwHhcNMjIwOTIyMDgzMDQ2WhcNMjIwOTIyMDg0MDQ2WjAAMFkwEwYH
KoZIzj0CAQYIKoZIzj0DAQcDQgAElNMrxlUpjhehAjo9v1F4mcrxReu7tNOzkUIW
27K3voNV/RMruAAuXVjc9BVqPPfYrJPMvGEnSztVe7zxrbP+4KOCAUYwggFCMA4G
A1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUbg1a
x9w/9jcbajdkJynERn9YcMEwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4Y
ZD8wIAYDVR0RAQH/BBYwFIESa25xeWYyNjNAZ21haWwuY29tMCwGCisGAQQBg78w
AQEEHmh0dHBzOi8vZ2l0aHViLmNvbS9sb2dpbi9vYXV0aDCBigYKKwYBBAHWeQIE
AgR8BHoAeAB2AAhgkvAoUv9oRdHRayeEnEVnGKwWPcM40m3mvCIGNm9yAAABg2RS
HOQAAAQDAEcwRQIgFtbSQlZKSlRtv5eMOvQQ0aMQnht/MnXRCKYTfN9EkdMCIQD6
lsaVDdYpd2Wo1Qe8v0jD0MJFRBSO05DSzVAF3Ep/qTAKBggqhkjOPQQDAwNoADBl
AjEArCuDiXb5RbFJGtCELNgRQBgbbWaawhZ08ey1N7Dt8NTjAYyTdi7x2WcV43dX
GIQnAjA9AnUNJEkBwABgZsdsZUB/lTc4UZeEFpTrB5s55ECbnG6MKRW3sZcnARzQ
cWE0bBk=
-----END CERTIFICATE-----

tlog entry created with index: 3732405
MEYCIQCoUlGiu9bPGMJcnFkl60s6T68sgRXNFHeHn9ZzrWMQbgIhANh5FutvBdewXjxhDv9L8uTJ/RBXdhJsRtJc7Dbwz3pt

ここまで読んできた人ならメッセージを見れば何をしているか分かると思いますが、使い捨ての鍵ペアを作ってFulcioに証明書を発行してもらっています。そのあとSCTの検証をして、署名と証明書をRekorにアップロードしています。最後に保存されたtlogのIDと署名が返って来ています。

Rekorに保存されたtlogは rekor-cli コマンドで見ることができます。このCLIのインストール方法は上のRekorのドキュメントに書いてあります。rekor-cli get に上のtlogのIDを指定します。

$ rekor-cli get --log-index 3732405
LogID: c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d
Index: 3732405
IntegratedTime: 2022-09-22T08:30:47Z
UUID: 34bd4d8bcd3b38b3ae4938bc8d56f9c38f29ab6592cebd2a7a391a3c98cd95d5
Body: {
  "HashedRekordObj": {
    "data": {
      "hash": {
        "algorithm": "sha256",
        "value": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
      }
    },
    "signature": {
      "content": "MEYCIQCoUlGiu9bPGMJcnFkl60s6T68sgRXNFHeHn9ZzrWMQbgIhANh5FutvBdewXjxhDv9L8uTJ/RBXdhJsRtJc7Dbwz3pt",
      "publicKey": {
        "content": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNvVENDQWllZ0F3SUJBZ0lVSnhVSkg5SzBuOE55MFFaUG9nckZBTk9FMDZNd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJd09USXlNRGd6TURRMldoY05Nakl3T1RJeU1EZzBNRFEyV2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVsTk1yeGxVcGpoZWhBam85djFGNG1jcnhSZXU3dE5PemtVSVcKMjdLM3ZvTlYvUk1ydUFBdVhWamM5QlZxUFBmWXJKUE12R0VuU3p0VmU3enhyYlArNEtPQ0FVWXdnZ0ZDTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVViZzFhCng5dy85amNiYWpka0p5bkVSbjlZY01Fd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0lBWURWUjBSQVFIL0JCWXdGSUVTYTI1eGVXWXlOak5BWjIxaGFXd3VZMjl0TUN3R0Npc0dBUVFCZzc4dwpBUUVFSG1oMGRIQnpPaTh2WjJsMGFIVmlMbU52YlM5c2IyZHBiaTl2WVhWMGFEQ0JpZ1lLS3dZQkJBSFdlUUlFCkFnUjhCSG9BZUFCMkFBaGdrdkFvVXY5b1JkSFJheWVFbkVWbkdLd1dQY000MG0zbXZDSUdObTl5QUFBQmcyUlMKSE9RQUFBUURBRWN3UlFJZ0Z0YlNRbFpLU2xSdHY1ZU1PdlFRMGFNUW5odC9NblhSQ0tZVGZOOUVrZE1DSVFENgpsc2FWRGRZcGQyV28xUWU4djBqRDBNSkZSQlNPMDVEU3pWQUYzRXAvcVRBS0JnZ3Foa2pPUFFRREF3Tm9BREJsCkFqRUFyQ3VEaVhiNVJiRkpHdENFTE5nUlFCZ2JiV2Fhd2haMDhleTFON0R0OE5UakFZeVRkaTd4MldjVjQzZFgKR0lRbkFqQTlBblVOSkVrQndBQmdac2RzWlVCL2xUYzRVWmVFRnBUckI1czU1RUNibkc2TUtSVzNzWmNuQVJ6UQpjV0UwYkJrPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
      }
    }
  }
}

data にSHA256のハッシュ値が含まれています。これは署名した対象のハッシュ値となっています。

$ sha256sum foo.txt
5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03  foo.txt

そして signature.content に署名が、 publicKey.content に証明書が含まれています。これで署名の検証可能な状態です。一応試しておきます。

まず証明書を抜き出します。Base64エンコードされているのでデコードして cert.pem に書き出します。

$ rekor-cli get --log-index 3732405 --format json | jq -r .Body.HashedRekordObj.signature.publicKey.content | base64 -d > cert.pem
$ openssl x509 -in cert.pem -inform PEM -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            27:15:09:1f:d2:b4:9f:c3:72:d1:06:4f:a2:0a:c5:00:d3:84:d3:a3
        Signature Algorithm: ecdsa-with-SHA384
        Issuer: O = sigstore.dev, CN = sigstore-intermediate
        Validity
            Not Before: Sep 22 08:30:46 2022 GMT
            Not After : Sep 22 08:40:46 2022 GMT
        Subject:
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub:
                    04:94:d3:2b:c6:55:29:8e:17:a1:02:3a:3d:bf:51:
                    78:99:ca:f1:45:eb:bb:b4:d3:b3:91:42:16:db:b2:
                    b7:be:83:55:fd:13:2b:b8:00:2e:5d:58:dc:f4:15:
                    6a:3c:f7:d8:ac:93:cc:bc:61:27:4b:3b:55:7b:bc:
                    f1:ad:b3:fe:e0
                ASN1 OID: prime256v1
                NIST CURVE: P-256
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature
            X509v3 Extended Key Usage:
                Code Signing
            X509v3 Subject Key Identifier:
                6E:0D:5A:C7:DC:3F:F6:37:1B:6A:37:64:27:29:C4:46:7F:58:70:C1
            X509v3 Authority Key Identifier:
                DF:D3:E9:CF:56:24:11:96:F9:A8:D8:E9:28:55:A2:C6:2E:18:64:3F
            X509v3 Subject Alternative Name: critical
                email:knqyf263@gmail.com
            1.3.6.1.4.1.57264.1.1:
                https://github.com/login/oauth
            CT Precertificate SCTs:
                Signed Certificate Timestamp:
                    Version   : v1 (0x0)
                    Log ID    : 08:60:92:F0:28:52:FF:68:45:D1:D1:6B:27:84:9C:45:
                                67:18:AC:16:3D:C3:38:D2:6D:E6:BC:22:06:36:6F:72
                    Timestamp : Sep 22 08:30:46.500 2022 GMT
                    Extensions: none
                    Signature : ecdsa-with-SHA256
                                30:45:02:20:16:D6:D2:42:56:4A:4A:54:6D:BF:97:8C:
                                3A:F4:10:D1:A3:10:9E:1B:7F:32:75:D1:08:A6:13:7C:
                                DF:44:91:D3:02:21:00:FA:96:C6:95:0D:D6:29:77:65:
                                A8:D5:07:BC:BF:48:C3:D0:C2:45:44:14:8E:D3:90:D2:
                                CD:50:05:DC:4A:7F:A9
    Signature Algorithm: ecdsa-with-SHA384
    Signature Value:
        30:65:02:31:00:ac:2b:83:89:76:f9:45:b1:49:1a:d0:84:2c:
        d8:11:40:18:1b:6d:66:9a:c2:16:74:f1:ec:b5:37:b0:ed:f0:
        d4:e3:01:8c:93:76:2e:f1:d9:67:15:e3:77:57:18:84:27:02:
        30:3d:02:75:0d:24:49:01:c0:00:60:66:c7:6c:65:40:7f:95:
        37:38:51:97:84:16:94:eb:07:9b:39:e4:40:9b:9c:6e:8c:29:
        15:b7:b1:97:27:01:1c:d0:71:61:34:6c:19

これはCosignが勝手にFulcioで発行した証明書です。OIDCで返されたメールアドレスなどが含まれています。この証明書から公開鍵を取り出します。ここまで真面目にやってきた人なら何も見ずにopensslのフラグを指定できるようになっていると思います。

$ openssl x509 -in cert.pem -pubkey -noout > pub.pem 

次に署名を取り出します。これもBase64エンコードされているのでデコードします。

$ rekor-cli get --log-index 3732405 --format json | jq -r .Body.HashedRekordObj.signature.content | base64 -d > foo.sig

では公開鍵と署名が手に入ったので元のテキストファイル( foo.txt )に対して署名検証してみます。

$ openssl dgst -sha256 -verify pub.pem -signature foo.sig foo.txt
Verified OK

ということでCosignによって内部的に発行された証明書をRekorから取得して署名検証できました。ちなみに上のコマンドだけでは証明書のチェーンの検証も何もしていないですし、これだけでは検証としては当然不十分です。

手動で試す

いつも通りcurlでやっていくのですが、その前に rekor-cli で署名をアップロードする方法を見てみます。

【参考】rekor-cliによるアップロード

署名をRekorに rekor-cli でアップロードする方法が公式ドキュメントにあります。まずはこの通りやってみます。もしかしたら途中で突っ込みたくなる人もいるかもしれませんが一旦気にせず進めてください。

docs.sigstore.dev

まず鍵ペアを作ります。

$ openssl ecparam -name prime256v1 -genkey -noout -out key.pem 
$ openssl ec -in key.pem -pubout -out pub.pem

次に署名します。ファイルは最初に作った foo.txt をそのまま使います。

$ openssl dgst -sha256 -sign key.pem -out foo.sig foo.txt 

そして署名を作ったのでこれを rekor-cli でアップロードします。

$ rekor-cli upload --artifact foo.txt --signature foo.sig --pki-format=x509 --public-key=pub.pem 
Created entry at index 3737384, available at: https://rekor.sigstore.dev/api/v1/log/entries/b844f6e9407fec02ea89623655f26a092b42554efdcaa595a209551f2f565a28

無事にtlogが作成されました。このログの中身を見てみます。

$ rekor-cli get --log-index 3737384
LogID: c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d
Index: 3737384
IntegratedTime: 2022-09-22T10:10:53Z
UUID: b844f6e9407fec02ea89623655f26a092b42554efdcaa595a209551f2f565a28
Body: {
  "RekordObj": {
    "data": {
      "hash": {
        "algorithm": "sha256",
        "value": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
      }
    },
    "signature": {
      "content": "MEYCIQDc8y97Was9RTtlCxusn3/aHTQB0/qFwSmmJsNl9CThzgIhAIxDMYhMmuD8eQ3GGTmfT3POF4eeTU/w1BuH6qdpLs67",
      "format": "x509",
      "publicKey": {
        "content": "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFbWd2RVVFMFBLTmo1WTlGeVpEbGMzNHg4NXV5Sgozdkp6ZlIyalJuZGtJWi9rVUxrWFBvZlFZODJHcEh0MmwzdDVPM2ZJR01oN09QSlUwdEJWVzVzdHhRPT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
      }
    }
  }
}

確かに保存されています。めでたしめでたし...と言いたいところですがそうはいかないです。まず前提としてRekorは証明書を保存してくれるものだと思っていたのですが、単なるPEM形式の公開鍵のアップロードに成功しています。この公開鍵は何にも署名されていないので証明書のチェーンも作れません。

rekor-cli が内部でいい感じに証明書を作っているのかも?と思ったので一応 signature.content.publicKey.content を見てみました。

$ rekor-cli get --log-index 3737384 --format json | jq -r .Body.RekordObj.signature.publicKey.content | base64 -d > a.pem
$ openssl ec -in a.pem -pubin -text -noout
read EC key
Private-Key: (256 bit)
pub:
    04:9a:0b:c4:50:4d:0f:28:d8:f9:63:d1:72:64:39:
    5c:df:8c:7c:e6:ec:89:de:f2:73:7d:1d:a3:46:77:
    64:21:9f:e4:50:b9:17:3e:87:d0:63:cd:86:a4:7b:
    76:97:7b:79:3b:77:c8:18:c8:7b:38:f2:54:d2:d0:
    55:5b:9b:2d:c5
ASN1 OID: prime256v1
NIST CURVE: P-256

やはりただの公開鍵です。公開鍵置き場としての役割も果たしたいのでしょうか。フィールド名も publicKey となっているぐらいなので想定されている用途だとは思いますが知らなかったので驚きました。

さらに謎なのは、フォーマットがX.509になっていることです。これはCLIフラグで指定したからなのですが、ドキュメントもそうなっています。

      "format": "x509",

自分の知る限りこれはX.509じゃないと思うのでもう何がなんだか分かりません。自分が知らないだけでX.509というのは単なる公開鍵も指すのでしょうか。ただGoのx509パッケージを見ると鍵のPEM<=>DER変換も含まれていたりするので、広義にはX.509なのかなと思うことにしました。

x509 package - crypto/x509 - Go Packages

公開鍵の場合は証明書チェーンの検証がスキップされるのかな?と思って一応Cosignで verify-blob をしてみましたが普通に失敗しました。

$ COSIGN_EXPERIMENTAL=1 cosign verify-blob foo.txt --signature foo.sig
WARNING: No valid entries were found in rekor to verify this blob.

Transparency log support for blobs is experimental, and occasionally an entry isn't found even if one exists.

We recommend requesting the certificate/signature from the original signer of this blob and manually verifying with cosign verify-blob --cert [cert] --signature [signature].
Error: verifying blob [foo.txt]: could not find a valid tlog entry for provided blob, found 20 invalid entries
main.go:62: error during command execution: verifying blob [foo.txt]: could not find a valid tlog entry for provided blob, found 20 invalid entries

謎すぎます。ただの公開鍵ストレージとしての機能なのでしょうか。ソースコードを確認したら公開鍵はやはり使えないようでした。

cosign/verify_blob.go at 63fe87501660595b7da9467078781e745272aa1b · sigstore/cosign · GitHub

curlでアップロード

参考として rekor-cli でのアップロードを試したらよく分からないことになりましたが、気を取り直してCosignにおけるRekorに対するリクエストを見てみます。rekor.sigstore.dev に対して以下のリクエストを投げています。

> POST /api/v1/log/entries HTTP/1.1
> Host: rekor.sigstore.dev
> Accept: application/json
> Content-Length: 1610
> Content-Type: application/json

{
    "apiVersion": "0.0.1",
    "spec": {
        "data": {
            "hash": {
                "algorithm": "sha256",
                "value": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
            }
        },
        "signature": {
            "content": "MEUCIQDLQvJl41+IK3o9eEEy7C+9VBxZfWzLUeHClKaJMZN/egIgVP15XLu0UUcdwnQwM2SMtgJ3gYWrHnkUzz4kHV2geOs=",
            "publicKey": {
                "content": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNuekNDQWlhZ0F3SUJBZ0lVUFVGNitVQ3MrZGNtK1NiazM3Q0VNZ2kzK1lVd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJd09USXlNRGt5TWpBM1doY05Nakl3T1RJeU1Ea3pNakEzV2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVvcUplZnQ3NnpLNWhtY2JNZ09LU1FSOXpPTEF5NjlDeG94NDIKazdGanpFOFdEeENWL3pOVlRTUmMwMDVMUmcxMGZUR2ZUM2NvWjV4bURUZFAvN2dvZktPQ0FVVXdnZ0ZCTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVQU0QyCktrbW5ZZW9qcStjcjlwblZLV2lUbk13d0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0lBWURWUjBSQVFIL0JCWXdGSUVTYTI1eGVXWXlOak5BWjIxaGFXd3VZMjl0TUN3R0Npc0dBUVFCZzc4dwpBUUVFSG1oMGRIQnpPaTh2WjJsMGFIVmlMbU52YlM5c2IyZHBiaTl2WVhWMGFEQ0JpUVlLS3dZQkJBSFdlUUlFCkFnUjdCSGtBZHdCMUFBaGdrdkFvVXY5b1JkSFJheWVFbkVWbkdLd1dQY000MG0zbXZDSUdObTl5QUFBQmcyU0IKSWVvQUFBUURBRVl3UkFJZ0NyWkk1Snp6WEJKZW1BZm9xNjc5WVZiL3hvVWVWNDRmVEdPem5IVy8wODhDSURQTwpSb054WTJNekdDSE1XTEp1RlZGakIvRXVWeWF2NDkvN25JQ0ZnL0N2TUFvR0NDcUdTTTQ5QkFNREEyY0FNR1FDCk1CWjFOclJnZnhpZ0UvNkI3TUJrTytsNzZyQ0t0bWJzRk5yZVZ2N01Mb0FDYytHOWVHM3lHSHhpSFZnUFI0SE4KZ2dJd1NOLzNBY3pLYjduQjdoSlAwVlNCNnUyTlNSdElhcklQQU1RNS9hMlpxKzlzSnFWQW1QNnZ6dXRwZXl1UwpoVHBvCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"
            }
        }
    },
    "kind": "hashedrekord"
}


* TLS connection using TLS 1.3 / TLS_AES_256_GCM_SHA384
* ALPN: h2 accepted
* Server certificate:
*  subject: CN=api.sigstore.dev
*  start date: Sun Jul 31 15:11:23 UTC 2022
*  expire date: Sat Oct 29 15:11:22 UTC 2022
*  issuer: CN=R3,O=Let's Encrypt,C=US
*  TLS certificate verify ok.
< HTTP/2.0 201 Created
< Content-Type: application/json
< Date: Thu, 22 Sep 2022 09:22:09 GMT
< Etag: 0b362725bee7d55998856d59b88be6cccea49c6c1d42700e31a3c6d655b0bed9
< Location: /api/v1/log/entries/0b362725bee7d55998856d59b88be6cccea49c6c1d42700e31a3c6d655b0bed9
< Strict-Transport-Security: max-age=15724800; includeSubDomains
< Vary: Origin

{
    "0b362725bee7d55998856d59b88be6cccea49c6c1d42700e31a3c6d655b0bed9": {
        "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1ODkxYjViNTIyZDVkZjA4NmQwZmYwYjExMGZiZDlkMjFiYjRmYzcxNjNhZjM0ZDA4Mjg2YTJlODQ2ZjZiZTAzIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJUURMUXZKbDQxK0lLM285ZUVFeTdDKzlWQnhaZld6TFVlSENsS2FKTVpOL2VnSWdWUDE1WEx1MFVVY2R3blF3TTJTTXRnSjNnWVdySG5rVXp6NGtIVjJnZU9zPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTnVla05EUVdsaFowRjNTVUpCWjBsVlVGVkdOaXRWUTNNclpHTnRLMU5pYXpNM1EwVk5aMmt6SzFsVmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlVU1hsTlJHdDVUV3BCTTFkb1kwNU5ha2wzVDFSSmVVMUVhM3BOYWtFelYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZ2Y1VwbFpuUTNObnBMTldodFkySk5aMDlMVTFGU09YcFBURUY1TmpsRGVHOTRORElLYXpkR2FucEZPRmRFZUVOV0wzcE9WbFJUVW1Nd01EVk1VbWN4TUdaVVIyWlVNMk52V2pWNGJVUlVaRkF2TjJkdlprdFBRMEZWVlhkblowWkNUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZRVTBReUNrdHJiVzVaWlc5cWNTdGpjamx3YmxaTFYybFViazEzZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBsQldVUldVakJTUVZGSUwwSkNXWGRHU1VWVFlUSTFlR1ZYV1hsT2FrNUJXakl4YUdGWGQzVlpNamwwVFVOM1IwTnBjMGRCVVZGQ1p6YzRkd3BCVVVWRlNHMW9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWMySXlaSEJpYVRsMldWaFdNR0ZFUTBKcFVWbExTM2RaUWtKQlNGZGxVVWxGQ2tGblVqZENTR3RCWkhkQ01VRkJhR2RyZGtGdlZYWTViMUprU0ZKaGVXVkZia1ZXYmtkTGQxZFFZMDAwTUcwemJYWkRTVWRPYlRsNVFVRkJRbWN5VTBJS1NXVnZRVUZCVVVSQlJWbDNVa0ZKWjBOeVdrazFTbnA2V0VKS1pXMUJabTl4TmpjNVdWWmlMM2h2VldWV05EUm1WRWRQZW01SVZ5OHdPRGhEU1VSUVR3cFNiMDU0V1RKTmVrZERTRTFYVEVwMVJsWkdha0l2UlhWV2VXRjJORGt2TjI1SlEwWm5MME4yVFVGdlIwTkRjVWRUVFRRNVFrRk5SRUV5WTBGTlIxRkRDazFDV2pGT2NsSm5abmhwWjBVdk5rSTNUVUpyVHl0c056WnlRMHQwYldKelJrNXlaVloyTjAxTWIwRkRZeXRIT1dWSE0zbEhTSGhwU0ZablVGSTBTRTRLWjJkSmQxTk9Mek5CWTNwTFlqZHVRamRvU2xBd1ZsTkNOblV5VGxOU2RFbGhja2xRUVUxUk5TOWhNbHB4S3pselNuRldRVzFRTm5aNmRYUndaWGwxVXdwb1ZIQnZDaTB0TFMwdFJVNUVJRU5GVWxSSlJrbERRVlJGTFMwdExTMEsifX19fQ==",
        "integratedTime": 1663838528,
        "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
        "logIndex": 3734692,
        "verification": {
            "signedEntryTimestamp": "MEUCIGhslYC6ITobDZMuRbE0Z2E4bCuh3KBBIXPJbrPphaNBAiEA6ggtcnBCRMV8+J7NOkULyv5r0lBKzG5Qn7IccwkSpqU="
        }
    }
}

該当コードは以下です。

cosign/tlog.go at 0baa044bea61e7c16d56023be20ead3d9204b24a · sigstore/cosign · GitHub

リクエストとしては datasignature を作れば良さそうです。

リクエストの作成

data 部分だけ取り出すと以下です。

        "data": {
            "hash": {
                "algorithm": "sha256",
                "value": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
            }
        },

これは署名対象のデータのハッシュ値です。既に計算済みですがもう一度やっておきます。

$ sha256sum foo.txt
5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03  foo.txt

signature 部分を抜き出すと以下です。

        "signature": {
            "content": "MEUCIQDLQvJl41+IK3o9eEEy7C+9VBxZfWzLUeHClKaJMZN/egIgVP15XLu0UUcdwnQwM2SMtgJ3gYWrHnkUzz4kHV2geOs=",
            "publicKey": {
                "content": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNuekNDQWlhZ0F3SUJBZ0lVUFVGNitVQ3MrZGNtK1NiazM3Q0VNZ2kzK1lVd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJd09USXlNRGt5TWpBM1doY05Nakl3T1RJeU1Ea3pNakEzV2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVvcUplZnQ3NnpLNWhtY2JNZ09LU1FSOXpPTEF5NjlDeG94NDIKazdGanpFOFdEeENWL3pOVlRTUmMwMDVMUmcxMGZUR2ZUM2NvWjV4bURUZFAvN2dvZktPQ0FVVXdnZ0ZCTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVQU0QyCktrbW5ZZW9qcStjcjlwblZLV2lUbk13d0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0lBWURWUjBSQVFIL0JCWXdGSUVTYTI1eGVXWXlOak5BWjIxaGFXd3VZMjl0TUN3R0Npc0dBUVFCZzc4dwpBUUVFSG1oMGRIQnpPaTh2WjJsMGFIVmlMbU52YlM5c2IyZHBiaTl2WVhWMGFEQ0JpUVlLS3dZQkJBSFdlUUlFCkFnUjdCSGtBZHdCMUFBaGdrdkFvVXY5b1JkSFJheWVFbkVWbkdLd1dQY000MG0zbXZDSUdObTl5QUFBQmcyU0IKSWVvQUFBUURBRVl3UkFJZ0NyWkk1Snp6WEJKZW1BZm9xNjc5WVZiL3hvVWVWNDRmVEdPem5IVy8wODhDSURQTwpSb054WTJNekdDSE1XTEp1RlZGakIvRXVWeWF2NDkvN25JQ0ZnL0N2TUFvR0NDcUdTTTQ5QkFNREEyY0FNR1FDCk1CWjFOclJnZnhpZ0UvNkI3TUJrTytsNzZyQ0t0bWJzRk5yZVZ2N01Mb0FDYytHOWVHM3lHSHhpSFZnUFI0SE4KZ2dJd1NOLzNBY3pLYjduQjdoSlAwVlNCNnUyTlNSdElhcklQQU1RNS9hMlpxKzlzSnFWQW1QNnZ6dXRwZXl1UwpoVHBvCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"
            }
        }

まず signature.content を作ります。これは単なる署名のBase64エンコードです。一旦署名をファイルに書き出してあとでBase64エンコードします。

$ openssl dgst -sha256 -sign key.pem -out foo.sig foo.txt

次に signature.publicKey.content ですが、これはFulcioから貰った証明書です。以前の記事で説明したので自分で試したい方はそちらを参照してください。以下では cert.pem に書き込まれている想定です。

ではJSONを作ります。

$ cat << EOF > req.json
{
    "apiVersion": "0.0.1",
    "spec": {
        "data": {
            "hash": {
                "algorithm": "sha256",
                "value": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
            }
        },
        "signature": {
            "content": "$(cat foo.sig | base64)",
            "publicKey": {
                "content": "$(base64 -w0 cert.pem)"
            }
        }
    },
    "kind": "hashedrekord"
}
EOF

完成したJSONを投げます。ちなみのこの証明書も10分で有効期限が切れてしまうので、ちゃんとやりたい場合は証明書の発行をやり直す必要があります。Rekor自体は有効期限が切れていても関係なく受け入れるので試しに署名や証明書を登録したいだけならやり直さなくて良いです。

$ curl -s -X POST https://rekor.sigstore.dev/api/v1/log/entries -H "Content-Type: application/json" -d @req.json
curl -X POST https://rekor.sigstore.dev/api/v1/log/entries -H "Content-Type: application/json" -d @req.json | jq .
{
  "24296fb24b8ad77aa715cdfd264ce34c4d544375d7bd7cd029bf5a48ef25217a13fdba562e0889ca": {
    "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1ODkxYjViNTIyZDVkZjA4NmQwZmYwYjExMGZiZDlkMjFiYjRmYzcxNjNhZjM0ZDA4Mjg2YTJlODQ2ZjZiZTAzIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJQUdRT3BaK0svbVpjSEZEMUpnSzBtZ3JNZENBZlRUWS9SQmUyV29aU2Z2SEFpRUF3Ky8wVDVpajVtMkJWdHpsY3JySlFoUzE4WGlwcDJwY1hKVEpFek56RG1NPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTnZWRU5EUVdsbFowRjNTVUpCWjBsVlVVc3hOREZhVldzeU9WaENURFpYUlhwVWFFdDJVR0ZUUnk5TmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlVU1RWTlJHc3dUbFJSZDFkb1kwNU5ha2wzVDFSSk5VMUVhekZPVkZGM1YycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZ2WVRWVFVXUXlTWE5QV0RKamVqRjNkSGh6VFVORVZtRlBhMnhDYjJoWVpWWmpjRk1LY0VoR1R5OWhiM05rTkZReFQwOXJiR3d3YkdSQ1JXTlplRkpyTDBWME1IWjRZMUpFTWtkcWRYQk1jRGxJVkhJMGMyRlBRMEZWV1hkblowWkRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZHSzFkSkNrdEpWR1p1Y1drNE5rbEZTazE0VUdGVFUyWm5ibk5CZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBsQldVUldVakJTUVZGSUwwSkNXWGRHU1VWVFlUSTFlR1ZYV1hsT2FrNUJXakl4YUdGWGQzVlpNamwwVFVOM1IwTnBjMGRCVVZGQ1p6YzRkd3BCVVVWRlNHMW9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWMySXlaSEJpYVRsMldWaFdNR0ZFUTBKcFoxbExTM2RaUWtKQlNGZGxVVWxGQ2tGblVqaENTRzlCWlVGQ01rRkJhR2RyZGtGdlZYWTViMUprU0ZKaGVXVkZia1ZXYmtkTGQxZFFZMDAwTUcwemJYWkRTVWRPYlRsNVFVRkJRbWMwYVdvS1RrVTBRVUZCVVVSQlJXTjNVbEZKWjBnd01EUjFUVzR6T1dzM1dqWTFkR3BLYm1FcmRHRlZXRnBxT1VaalF6WXpRVmRNYUUxa09WcHRiMjlEU1ZGRGVBbzRMMWhtUWxwbVYwTTNObXRtTUc5aFltcDRhVWQwUWtvemRFSnBSSGR2TmpGck5YTlZORmRaWVZSQlMwSm5aM0ZvYTJwUFVGRlJSRUYzVG05QlJFSnNDa0ZxUlVGMVFqSnhia0pUUVcxR2JtOWxiek52UVhCRVJIbHBNRlZaWkRSNk5sSXJOVWd6UVU4MFozbE1ZMXBIUlRJMGJGSlpSelpIUlVKWGRWSTRSemdLZG1keGRrRnFRVFJUVmtZNVdtbzJiMVY2Y0ZWRFR6RjFhbkprUnpFNGJ6QkZLMkYzT0RaS1JFUkpNekpsYVZKdVRVUTNWR2syUm5FeVpHMW1WMnhKWXdweFJFSkhibFpyUFFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PSJ9fX19",
    "integratedTime": 1664451604,
    "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
    "logIndex": 4215471,
    "verification": {
      "inclusionProof": {
        "checkpoint": "rekor.sigstore.dev - 2605736670972794746\n52041\n8XyMdDqdv/cS0idaNcLRAahDKaxecZIY0eyHEQT9iFs=\nTimestamp: 1664451605152835877\n\n— rekor.sigstore.dev wNI9ajBFAiEAyEA/+Jrqmpp3RSlJcvVACQdWcFLpQupa7ws3Nvfn+TICIC2waHRzaGF60EZ1BTz/eXSdvbBEJWRjSZVabWbyh3tK\n",
        "hashes": [
          "7cbfd51112b902a865bfddbb03846815ca2f83ccbd11cbbc7e55150f8443e762",
          "f28f0e5cdd8ba0206fc63e06300a8045a4df720eb2232da0b45a8df3b0facb6d",
          "48e2551406feb46ca38676189b6d660b0ea3f6bb39513638ee16394cc4f00f4b",
          "a145e2ef3b678e1a12456c052caa2847a84209c60b48ba513dedf675810ee386",
          "2d9ed8909dc7e1c5c12d736e17441e97800a8c3b76d1158e2a91880d8925b8c1",
          "f015b45ef62857687ecc78cbdbd39506cc5c46efc45ed6a8814f17bb88fd8e6b",
          "3972997e9a268e76f88943ed840463d9aea69ab4b8c270ddd6c2c0287896b7b6"
        ],
        "logIndex": 52040,
        "rootHash": "f17c8c743a9dbff712d2275a35c2d101a84329ac5e719218d1ec871104fd885b",
        "treeSize": 52041
      },
      "signedEntryTimestamp": "MEYCIQDnxNLe8hpmTLjdY3ASHqO/dlLfozzNKruPeNZAMMYoHAIhAJ3Q/bon3FK+q+rspp2RLffalJZsdiT4A/grirCjWC0g"
    }
  }
}

Signed Entry Timestampの検証

レスポンスを見ると signedEntryTimestamp という値が含まれています。これはSigned Entry Timestamp (SET) のことで、Certificate TransparencyにおけるSigned Certificate Timestamp (SCT)とほぼ同じアイディアです。SCTについては前回のブログで説明しましたが、SCTは確かにCTログサーバにログが保存されましたよという証明のための時刻への署名で、SETも同様に確かにRekorにログが保存されましたよということを証明するための時刻への署名です。このSETをどのように検証するのかソースコードを見てみます。

cosign/verify.go at 80b79ed8b4d28ccbce3d279fd273606b5cddcc25 · sigstore/cosign · GitHub

cbundle.RekorPayloadJSONシリアライズしたあとにCanonicalization (JSON Canonicalization Schema, JCS)を行っています。これは同じJSONでもキーの順序が違ったり改行の有無だったりインデントの数だったり表現方法が複数あり署名に不便なので、必ず同じJSONが同じバイト列になるようにフォーマットの方法を定義したものです。詳細は以下に定義されています。

draft-rundgren-json-canonicalization-scheme-02

bundle.RekorPayload は以下に定義されています。

cosign/rekor.go at a7bd67c0a7314200ed72e5d9870911e6149d76bf · sigstore/cosign · GitHub

body, integratedTime, logIndex, logID の4つが bundle.RekorPayload を組み立てるために必要ということがわかります。

ということでまとめるとSETの署名検証に必要なのは以下の3つです。署名元と公開鍵と署名が必要というだけなので通常通りです。

  • 署名( signedEntryTimestamp
  • Rekorの公開鍵
  • 署名対象( body, integratedTime, logIndex, logID )

署名対象の4つは全て上のレスポンスボディに含まれています。上からコピペしつつJSONを手で組み立ててみます。

{
        "integratedTime": 1664451604,
        "logIndex": 4215471,
        "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1ODkxYjViNTIyZDVkZjA4NmQwZmYwYjExMGZiZDlkMjFiYjRmYzcxNjNhZjM0ZDA4Mjg2YTJlODQ2ZjZiZTAzIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJQUdRT3BaK0svbVpjSEZEMUpnSzBtZ3JNZENBZlRUWS9SQmUyV29aU2Z2SEFpRUF3Ky8wVDVpajVtMkJWdHpsY3JySlFoUzE4WGlwcDJwY1hKVEpFek56RG1NPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTnZWRU5EUVdsbFowRjNTVUpCWjBsVlVVc3hOREZhVldzeU9WaENURFpYUlhwVWFFdDJVR0ZUUnk5TmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlVU1RWTlJHc3dUbFJSZDFkb1kwNU5ha2wzVDFSSk5VMUVhekZPVkZGM1YycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZ2WVRWVFVXUXlTWE5QV0RKamVqRjNkSGh6VFVORVZtRlBhMnhDYjJoWVpWWmpjRk1LY0VoR1R5OWhiM05rTkZReFQwOXJiR3d3YkdSQ1JXTlplRkpyTDBWME1IWjRZMUpFTWtkcWRYQk1jRGxJVkhJMGMyRlBRMEZWV1hkblowWkRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZHSzFkSkNrdEpWR1p1Y1drNE5rbEZTazE0VUdGVFUyWm5ibk5CZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBsQldVUldVakJTUVZGSUwwSkNXWGRHU1VWVFlUSTFlR1ZYV1hsT2FrNUJXakl4YUdGWGQzVlpNamwwVFVOM1IwTnBjMGRCVVZGQ1p6YzRkd3BCVVVWRlNHMW9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWMySXlaSEJpYVRsMldWaFdNR0ZFUTBKcFoxbExTM2RaUWtKQlNGZGxVVWxGQ2tGblVqaENTRzlCWlVGQ01rRkJhR2RyZGtGdlZYWTViMUprU0ZKaGVXVkZia1ZXYmtkTGQxZFFZMDAwTUcwemJYWkRTVWRPYlRsNVFVRkJRbWMwYVdvS1RrVTBRVUZCVVVSQlJXTjNVbEZKWjBnd01EUjFUVzR6T1dzM1dqWTFkR3BLYm1FcmRHRlZXRnBxT1VaalF6WXpRVmRNYUUxa09WcHRiMjlEU1ZGRGVBbzRMMWhtUWxwbVYwTTNObXRtTUc5aFltcDRhVWQwUWtvemRFSnBSSGR2TmpGck5YTlZORmRaWVZSQlMwSm5aM0ZvYTJwUFVGRlJSRUYzVG05QlJFSnNDa0ZxUlVGMVFqSnhia0pUUVcxR2JtOWxiek52UVhCRVJIbHBNRlZaWkRSNk5sSXJOVWd6UVU4MFozbE1ZMXBIUlRJMGJGSlpSelpIUlVKWGRWSTRSemdLZG1keGRrRnFRVFJUVmtZNVdtbzJiMVY2Y0ZWRFR6RjFhbkprUnpFNGJ6QkZLMkYzT0RaS1JFUkpNekpsYVZKdVRVUTNWR2syUm5FeVpHMW1WMnhKWXdweFJFSkhibFpyUFFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PSJ9fX19",
        "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d"
}

これだとJCSでフォーマットされていないので、変換します。JCSはjqでサポートされているのかと思ったのですが見つけられなかったので勝手にオレオレ実装でやります。キーを並び替えて改行やスペースを消すだけの簡易なものでも一応検証は通りました。ちゃんとやる場合は何かライブラリを使うなどしたほうが良いです。

$ jq --sort-keys . set.json | tr -d "\n " | tee set.canonical.json
{"body":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1ODkxYjViNTIyZDVkZjA4NmQwZmYwYjExMGZiZDlkMjFiYjRmYzcxNjNhZjM0ZDA4Mjg2YTJlODQ2ZjZiZTAzIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJQUdRT3BaK0svbVpjSEZEMUpnSzBtZ3JNZENBZlRUWS9SQmUyV29aU2Z2SEFpRUF3Ky8wVDVpajVtMkJWdHpsY3JySlFoUzE4WGlwcDJwY1hKVEpFek56RG1NPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTnZWRU5EUVdsbFowRjNTVUpCWjBsVlVVc3hOREZhVldzeU9WaENURFpYUlhwVWFFdDJVR0ZUUnk5TmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlVU1RWTlJHc3dUbFJSZDFkb1kwNU5ha2wzVDFSSk5VMUVhekZPVkZGM1YycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZ2WVRWVFVXUXlTWE5QV0RKamVqRjNkSGh6VFVORVZtRlBhMnhDYjJoWVpWWmpjRk1LY0VoR1R5OWhiM05rTkZReFQwOXJiR3d3YkdSQ1JXTlplRkpyTDBWME1IWjRZMUpFTWtkcWRYQk1jRGxJVkhJMGMyRlBRMEZWV1hkblowWkRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZHSzFkSkNrdEpWR1p1Y1drNE5rbEZTazE0VUdGVFUyWm5ibk5CZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBsQldVUldVakJTUVZGSUwwSkNXWGRHU1VWVFlUSTFlR1ZYV1hsT2FrNUJXakl4YUdGWGQzVlpNamwwVFVOM1IwTnBjMGRCVVZGQ1p6YzRkd3BCVVVWRlNHMW9NR1JJUW5wUGFUaDJXakpzTUdGSVZtbE1iVTUyWWxNNWMySXlaSEJpYVRsMldWaFdNR0ZFUTBKcFoxbExTM2RaUWtKQlNGZGxVVWxGQ2tGblVqaENTRzlCWlVGQ01rRkJhR2RyZGtGdlZYWTViMUprU0ZKaGVXVkZia1ZXYmtkTGQxZFFZMDAwTUcwemJYWkRTVWRPYlRsNVFVRkJRbWMwYVdvS1RrVTBRVUZCVVVSQlJXTjNVbEZKWjBnd01EUjFUVzR6T1dzM1dqWTFkR3BLYm1FcmRHRlZXRnBxT1VaalF6WXpRVmRNYUUxa09WcHRiMjlEU1ZGRGVBbzRMMWhtUWxwbVYwTTNObXRtTUc5aFltcDRhVWQwUWtvemRFSnBSSGR2TmpGck5YTlZORmRaWVZSQlMwSm5aM0ZvYTJwUFVGRlJSRUYzVG05QlJFSnNDa0ZxUlVGMVFqSnhia0pUUVcxR2JtOWxiek52UVhCRVJIbHBNRlZaWkRSNk5sSXJOVWd6UVU4MFozbE1ZMXBIUlRJMGJGSlpSelpIUlVKWGRWSTRSemdLZG1keGRrRnFRVFJUVmtZNVdtbzJiMVY2Y0ZWRFR6RjFhbkprUnpFNGJ6QkZLMkYzT0RaS1JFUkpNekpsYVZKdVRVUTNWR2syUm5FeVpHMW1WMnhKWXdweFJFSkhibFpyUFFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PSJ9fX19","integratedTime":1664451604,"logID":"c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d","logIndex":4215471} 

Rekorの公開鍵はRekorのAPIで公開されているので取ってきます。

$ curl -O https://rekor.sigstore.dev/api/v1/log/publicKey

これで全て揃いました。あとは signedEntryTimestampBase64エンコードされているのでデコードしておきます。

$ echo -n "MEYCIQDnxNLe8hpmTLjdY3ASHqO/dlLfozzNKruPeNZAMMYoHAIhAJ3Q/bon3FK+q+rspp2RLffalJZsdiT4A/grirCjWC0g" | base64 -d > set.sig

これを --signature に渡します。

$ openssl dgst -sha256 -verify publicKey --signature set.sig set.canonical.json
Verified OK

ということで検証に成功しました。

ちなみに logIndexlogID って違い何?と気になるかもしれませんが、 logIndex はいわゆるIDでログの登録される際に採番される連番の数値です。 logID はログ保存時のRekorの公開鍵のDERエンコードハッシュ値となっています。

$ curl -s https://rekor.sigstore.dev/api/v1/log/publicKey  | openssl ec -outform der -pubin | shasum -a 256
read EC key
writing EC key
c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d  -

で確かに上の値と一致します。正直名前分かりにくすぎない...?と思っています。以下のコメントを読んで知りました。

rekor/log_entry.go at 7f3a2561dd94d48ca3554fdf07340b76ba275149 · sigstore/rekor · GitHub

余談

上のRekorへのリクエストの中で対象データのハッシュ値をアップロードしていましたが、この spec.data.hash.value の値がおかしいと弾かれました。サーバ側で署名検証に成功するかどうかを見てから保存しているようです。

# spec.ddata.hash.valueの値がおかしい
$ cat req.json
{
    "apiVersion": "0.0.1",
    "spec": {
        "data": {
            "hash": {
                "algorithm": "sha256",
                "value": "0a5005357763a89c7a36b43d19a41ab534d86f5346e1d1a64791279409f0688e"
            }
        },
        "signature": {
            "content": "MEUCIAGQOpZ+K/mZcHFD1JgK0mgrMdCAfTTY/RBe2WoZSfvHAiEAw+/0T5ij5m2BVtzlcrrJQhS1
8Xipp2pcXJTJEzNzDmM=",
            "publicKey": {
                "content": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNvVENDQWllZ0F3SUJBZ0lVUUsxNDFaVWsyOVhCTDZXRXpUaEt2UGFTRy9Nd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJd09USTVNRGswTlRRd1doY05Nakl3T1RJNU1EazFOVFF3V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVvYTVTUWQySXNPWDJjejF3dHhzTUNEVmFPa2xCb2hYZVZjcFMKcEhGTy9hb3NkNFQxT09rbGwwbGRCRWNZeFJrL0V0MHZ4Y1JEMkdqdXBMcDlIVHI0c2FPQ0FVWXdnZ0ZDTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVGK1dJCktJVGZucWk4NklFSk14UGFTU2ZnbnNBd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0lBWURWUjBSQVFIL0JCWXdGSUVTYTI1eGVXWXlOak5BWjIxaGFXd3VZMjl0TUN3R0Npc0dBUVFCZzc4dwpBUUVFSG1oMGRIQnpPaTh2WjJsMGFIVmlMbU52YlM5c2IyZHBiaTl2WVhWMGFEQ0JpZ1lLS3dZQkJBSFdlUUlFCkFnUjhCSG9BZUFCMkFBaGdrdkFvVXY5b1JkSFJheWVFbkVWbkdLd1dQY000MG0zbXZDSUdObTl5QUFBQmc0aWoKTkU0QUFBUURBRWN3UlFJZ0gwMDR1TW4zOWs3WjY1dGpKbmErdGFVWFpqOUZjQzYzQVdMaE1kOVptb29DSVFDeAo4L1hmQlpmV0M3NmtmMG9hYmp4aUd0QkozdEJpRHdvNjFrNXNVNFdZYVRBS0JnZ3Foa2pPUFFRREF3Tm9BREJsCkFqRUF1QjJxbkJTQW1Gbm9lbzNvQXBERHlpMFVZZDR6NlIrNUgzQU80Z3lMY1pHRTI0bFJZRzZHRUJXdVI4RzgKdmdxdkFqQTRTVkY5Wmo2b1V6cFVDTzF1anJkRzE4bzBFK2F3ODZKRERJMzJlaVJuTUQ3VGk2RnEyZG1mV2xJYwpxREJHblZrPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="
            }
        }
    },
    "kind": "hashedrekord"
}
$ curl -X POST https://rekor.sigstore.dev/api/v1/log/entries -H "Content-Type: application/json" -d @req.json
{"code":400,"message":"Error processing entry: verifying signature: invalid signature when validating ASN.1 encoded signature"}

上述した通り、 publicKey の部分を公開鍵にしても通ります。

$ cat pubkey.pem | base64 -w0
LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFb2E1U1FkMklzT1gyY3oxd3R4c01DRFZhT2tsQgpvaFhlVmNwU3BIRk8vYW9zZDRUMU9Pa2xsMGxkQkVjWXhSay9FdDB2eGNSRDJHanVwTHA5SFRyNHNRPT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==

署名検証できない公開鍵は弾かれました。つまり証明書であれ公開鍵であれ検証はしているようです。

さらに自己署名証明書も通りました。Fulcioで発行されたもののみ通るような仕様にはなっていないようです。

$ openssl req -new -x509 -days 3650 -key key.pem -sha512 -out server.crt
$ curl -X POST https://rekor.sigstore.dev/api/v1/log/entries -H "Content-Type: application/json" -d @req.json
{"907a8e725e8358c0ee2d050568fbc442472149d2d701a45828db074f941db393":{"body":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI1ODkxYjViNTIyZDVkZjA4NmQwZmYwYjExMGZiZDlkMjFiYjRmYzcxNjNhZjM0ZDA4Mjg2YTJlODQ2ZjZiZTAzIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJRUdrd2NCaWppVDhWTFIyUUkyNDF4NXRvV3l4Zy9acmdweWVYbXZuRnRZeEFpRUFxamVhUWRhaUZORmIyT0tHRnM1ODhvM3p1UE1tUlZIaFU2OGVWZjN5RHNvPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVSkVSRU5DZEVGSlNrRktPVVJKZVZod2JVUkJkazFCYjBkRFEzRkhVMDAwT1VKQlRVVk5RVGg0UkZSQlRFSm5UbFpDUVUxTlFrWlNiR016VVhjS1NHaGpUazFxU1hkUFZFbDVUVlJGZDA5VVRYcFhhR05PVFhwSmQwOVVSVFZOVkVWM1QxUk5lbGRxUVZCTlVUQjNRM2RaUkZaUlVVUkVRVkpWV2xoT01BcE5SbXQzUlhkWlNFdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVlRVVVJuZW1Gc1JIWjJSRlJXTms5UlZtcHFiR3hKYTNsV1pXTkNDbFU1ZDA5emFUbDNTRTVYVmpkVlJFMUJieXRGU1cxTGNGY3JjSFpTTVcxME9GZG1jM2xZWTJ4bk1uTjVURzV0UTNOM1lVVkJkRzByYkVSQlMwSm5aM0VLYUd0cVQxQlJVVVJDUVU1SVFVUkNSVUZwUW5aemRraDZMelU0UXk5eGExWldSR2RWSzNGalFtWlNjV3gzUTJ0WFZWZHhkRlpPU2pGWGQzSmlNRUZKWndwVVRXZFlTbkZuU1hWWVNFcDZLM0p1V1VrclRWWkxWbXBqZUVsRVNFRnZaREppTnpWM1FtUjJVRzkzUFFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PSJ9fX19","integratedTime":1663845107,"logID":"c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d","logIndex":3740608,"verification":{"signedEntryTimestamp":"MEQCIFUFVxQ3+5/+N+qPoa/KeIcxoU0Xp4YNTzryQ9+ck1AvAiBGx5rLZ8FntXinOhDhzqK0YLJ8Eh6RKO+8mWweJ2Zg+Q=="}}}

つまりRekorから取ってきた証明書は無条件に信頼できるわけではなく、Keyless Signingの検証をするためにはまずFulcioの証明書チェーンを検証する必要があります。この検証は次の記事でやってみます。

まとめ

署名と証明書をRekorに手動で保存してみました。Rekorは他にもattestationと呼ばれるものも保存できたりします。in-toto attestationについては以下のブログに詳しく書かれています。

otameshi61.hatenablog.com

今回までで一通りKeylessな署名が完了しました。次のブログでは検証で行われていることを見ていきます。

curlでKeyless Signingする (3) - Signed Certificate Timestamp編

はじめに

sigstoreのKeyless SigningをCosignコマンドを使わずに手動で頑張るシリーズの三本目です。誰にも望まれてないけどやりきります。

前回はFulcioで証明書の発行までを行いました。Fulcioから返された署名の中にSigned Certificate Timestamp (SCT) が含まれているので、今回はそれの検証をしてみます。シュッと終わらせるつもりがドチャクソハマったので1つの記事にしました。一つだけ言っておきたいのは手動でCLIで検証するとかわけのわからないことを言ってないでおとなしくプログラムを書いたほうが良いということです。

あとKeylessとか言ってますがもはや関係なくてただのSCT検証になっています。ただCosignのコードを読んで挙動を理解したものを書いているので、一般的なSCTとは少し異なる可能性があります。

Cosignのコードと言いましたが、Cosignは内部で以下のライブラリを利用しており大半の処理はこのライブラリから来ています。

github.com

CosignにおけるSCT検証は以下の関数なのでこの中を追っていけば良いです。

cosign/verify.go at 31e665415f2da47356e4657e751443fcf5f394ed · sigstore/cosign · GitHub

SCTとは?

Signed Certificate Timestamp (SCT)とはCertificate Transparency (CT) で登場する用語で、CTログサーバに証明書を登録したときに返却されます。登録した日時に署名をしたもので、認証局はこのSCTをさらに証明書に埋め込んでクライアントに返します。

CTについては以前少しブログを書きました。 knqyf263.hatenablog.com

CTそのものについての基本的な内容は以下のスライドが分かりやすいです。

https://ozuma.sakura.ne.jp/sumida/wp-content/uploads/2015/04/01_cert_t-view.pdf

そしてSCTの細かい話は以下に書かれています。 ozuma.hatenablog.jp

sigstoreにおけるSCT

FulcioにおけるCertificate Transparency Logの仕組みは以下に書いてあります。前回の記事で説明したとおりです。

github.com

一応もう一度簡単に説明しておくと、FulcioがX.509の拡張領域に毒を入れてPrecertificateを作成し、それをCTログサーバにアップロードします。そしてCTログサーバが証明書を受け取った時刻に署名してSCTを返します。この辺は一般的なCTの仕組みと同じだと思います。

そして得たSCTを証明書に埋め込み、Fulcioで再度署名を行います。

なぜPrecertificatを用意してまで2回も署名するの?という点については上記の参考ブログに書いてありますが、鶏が先か卵が先かというやつです。証明書にSCTを埋め込みたいけど証明書がなければSCTが作れないので、仕方なくCT用の証明書としてPrecertificateを作ってるということです。CTのこの仕組み自体がイケてないよね、という話はあると思いますが一旦置いておきます。

SCTの確認

流れを理解したので、前回の記事で取得した証明書を再度確認します。

$ openssl x509 -text -in cert.pem
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            57:bb:87:ed:6f:23:30:a4:7b:56:48:b8:e6:cf:ae:20:d4:44:f4:da
        Signature Algorithm: ecdsa-with-SHA384
        Issuer: O = sigstore.dev, CN = sigstore-intermediate
        Validity
            Not Before: Sep 20 07:58:11 2022 GMT
            Not After : Sep 20 08:08:11 2022 GMT
        Subject:
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub:
                    04:ff:92:f6:52:7f:e2:6c:b9:43:09:44:ac:bb:cd:
                    3f:cb:3a:13:a6:cc:b4:ad:16:9a:05:33:7f:90:fc:
                    9e:99:51:28:ea:36:ac:54:15:49:b4:68:9e:63:8d:
                    64:0e:ee:e8:38:38:84:9f:48:06:eb:d3:7b:0a:2d:
                    89:2e:24:09:25
                ASN1 OID: prime256v1
                NIST CURVE: P-256
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature
            X509v3 Extended Key Usage:
                Code Signing
            X509v3 Subject Key Identifier:
                21:96:4C:19:04:59:56:D6:E3:B7:43:5D:AF:4D:63:97:26:1B:89:F8
            X509v3 Authority Key Identifier:
                DF:D3:E9:CF:56:24:11:96:F9:A8:D8:E9:28:55:A2:C6:2E:18:64:3F
            X509v3 Subject Alternative Name: critical
                email:knqyf263@gmail.com
            1.3.6.1.4.1.57264.1.1:
                https://github.com/login/oauth
            CT Precertificate SCTs:
                Signed Certificate Timestamp:
                    Version   : v1 (0x0)
                    Log ID    : 08:60:92:F0:28:52:FF:68:45:D1:D1:6B:27:84:9C:45:
                                67:18:AC:16:3D:C3:38:D2:6D:E6:BC:22:06:36:6F:72
                    Timestamp : Sep 20 07:58:11.540 2022 GMT
                    Extensions: none
                    Signature : ecdsa-with-SHA256
                                30:45:02:20:65:9B:88:5C:13:9E:0E:FC:CE:7F:78:D8:
                                77:CE:E2:C9:DC:34:A6:3C:8C:B3:A1:68:74:40:53:24:
                                DD:35:43:6C:02:21:00:C1:4D:BB:E1:7D:43:76:DC:C7:
                                20:B2:58:D9:87:FC:EE:C3:74:A1:0E:DE:40:CA:8A:A9:
                                67:58:94:0A:6A:C1:91
    Signature Algorithm: ecdsa-with-SHA384
    Signature Value:
        30:65:02:30:30:de:6d:9c:10:ed:57:75:00:5a:2e:a7:55:e0:
        53:df:1d:a3:e7:b6:93:79:3d:23:ed:2c:89:ae:d5:49:07:34:
        eb:91:e5:af:67:36:97:f2:bb:37:78:92:80:8c:06:64:02:31:
        00:fb:09:54:f0:46:81:1b:3d:1b:ee:c0:ab:26:13:d5:a3:b2:
        d6:3e:4b:4e:5f:2a:99:52:37:b8:ef:3e:d0:86:40:89:38:0f:
        b6:ba:6a:8f:60:49:27:f9:12:9d:22:ab:1a

CT Precertificate SCTs の箇所にSCTの詳細が書かれています。肝心のTimestampに加え、Log IDやSignatureが入っています。

CTログサーバの公開鍵取得

TUFという仕組みでCTログサーバの公開鍵を取得しています。TUFは未だに理解できていないですが、とりあえず安全に最新の公開鍵を取得可能なようです。いつか余裕があればちゃんと調べたいです。以下に公開鍵などの一覧があります。

https://sigstore-tuf-root.storage.googleapis.com/

このうち、ctfe.pubが目的の鍵なのでこれをダウンロードします。

$ curl -O https://sigstore-tuf-root.storage.googleapis.com/targets/ctfe.pub

Log IDの検証

上でLog IDというものがありましたが、今落としてきた公開鍵がこのLog IDと一致することを最初に確認します。CTにおけるLog IDは単にCTログサーバの公開鍵をDER形式にしてSHA256のハッシュを取るだけのようです。以下に詳しく書かれています。

zkat.hatenablog.com

落としてきた ctfe.pub を使って上記のLog IDと一致することを確認します。

$ openssl ec -outform der -pubin -in ctfe.pub | openssl dgst -SHA256
read EC key
writing EC key
SHA2-256(stdin)= 086092f02852ff6845d1d16b27849c456718ac163dc338d26de6bc2206366f72

上の証明書内のLog IDは 08:60:92...:6F:72 なので確かに一致しました。

図にするまでもないですが一応。

署名検証

では次にこの公開鍵を使ってSCTの署名を検証するのですが、ここが全然うまく行かなくて時間がかかりました。ググっても見つからなかったのでCLIでSCT検証をしたの世界初なんじゃないでしょうか。SCTが証明書に埋め込まれていない場合のやり方は以下にあるので参考になりますが、今回は上記の通り埋め込まれているのでこのやり方ではうまく行かないです。

blog.pierky.com

証明書に埋め込まれているSCTに対して署名検証ができるのかと思いきや、自分で署名対象を組み立てる必要がある点が難しいです。

上図のように複数の要素によって構成された値から署名は生成されています。以下で手でやってみます。

SCTのデータフォーマット

Signed Certificate TimestampのフォーマットはRFC 6962の3.2で定義されています。

RFC 6962: Certificate Transparency

まず事前に関連する型の定義です。この型達は後半で署名元を組み立てるときに出てきます。

enum { x509_entry(0), precert_entry(1), (65535) } LogEntryType;

enum { certificate_timestamp(0), tree_hash(1), (255) }
    SignatureType;

enum { v1(0), (255) }
    Version;

struct {
    opaque key_id[32];
} LogID;

opaque TBSCertificate<1..2^24-1>;

struct {
    opaque issuer_key_hash[32];
    TBSCertificate tbs_certificate;
} PreCert;

opaque CtExtensions<0..2^16-1>;

そしてSCTの定義は以下です。

struct {
    Version sct_version;
    LogID id;
    uint64 timestamp;
    CtExtensions extensions;
    digitally-signed struct {
        Version sct_version;
        SignatureType signature_type = certificate_timestamp;
        uint64 timestamp;
        LogEntryType entry_type;
        select(entry_type) {
            case x509_entry: ASN.1Cert;
            case precert_entry: PreCert;
        } signed_entry;
        CtExtensions extensions;
    };
} SignedCertificateTimestamp;

型情報を無視すると上から順に

  • sct_version
  • id
  • timestamp
  • extensions
  • digitally-signed

の5つがSCTには含まれるということが分かります。再度上の証明書内のSCTのフィールドを見てみます。

CT Precertificate SCTs:
    Signed Certificate Timestamp:
         Version   : v1 (0x0)
         Log ID    : 08:60:92:F0:28:52:FF:68:45:D1:D1:6B:27:84:9C:45:
                          67:18:AC:16:3D:C3:38:D2:6D:E6:BC:22:06:36:6F:72
         Timestamp : Sep 20 07:58:11.540 2022 GMT
         Extensions: none
         Signature : ecdsa-with-SHA256
                       30:45:02:20:65:9B:88:5C:13:9E:0E:FC:CE:7F:78:D8:
                       ...

確かに

  • Version
  • Log ID
  • Timestamp
  • Extensions
  • Signature

の5つが含まれています。

この digitally-signed の部分は署名の値だけが証明書に含まれています。つまり署名した対象は含まれていないので署名元は自分で組み立てる必要があります。

digitally-signed struct {
    Version sct_version;
    SignatureType signature_type = certificate_timestamp;
    uint64 timestamp;
    LogEntryType entry_type;
    select(entry_type) {
        case x509_entry: ASN.1Cert;
        case precert_entry: PreCert;
    } signed_entry;
    CtExtensions extensions;
};

各フィールドの値を入手したとしてどうやってエンコードするのか疑問かもしれませんが、エンコード方法はRFC5246に書いてあります。

RFC 5246: The Transport Layer Security (TLS) Protocol Version 1.2

以下の5つの値を入手すれば署名元を作れます。

  • sct_version
  • signature_type
  • entry_type
  • signed_entry
  • extensions

それぞれ見ていきます。

sct_version

ここは v1 なので0です。

enum { v1(0), (255) }
    Version;

signature_type

ここは certificate_timestamp なので0です。

enum { certificate_timestamp(0), tree_hash(1), (255) }
    SignatureType;

timestamp

タイムスタンプはミリ秒まで含んだepoch timeです。

"timestamp" is the current NTP Time [RFC5905], measured since the epoch (January 1, 1970, 00:00), ignoring leap seconds, in milliseconds.

今回は Sep 20 07:58:11.540 2022 GMT なのでまずepochに直します。

$ date -d 'Sep 20 07:58:11.540 2022 GMT' +%s%3N
1663660691540

そしてこれを16進数に直します。

$ printf "%x\n" 1663660691540
18359e79054

この数値をuint64として扱えばOKです。

entry_type

LogEntryTypeですが、2種類の値が選択可能です。

enum { x509_entry(0), precert_entry(1), (65535) } LogEntryType;

今回はPrecertificateに対してSCTを作っているので precert_entry(1) になります。ここで x509_entry(0) にしていたせいで検証が通らず無限にハマりました。 x509_entry(0)TLS extensionやOCSP StaplingなどSCTを埋め込まない場合に使われるのかと想像していますが、そこまではちゃんと調べてないです。

signed_entry

上で選択した entry_type に応じて型が変わります。今回は precert_entry(1) なので PreCert になります。

select(entry_type) {
    case x509_entry: ASN.1Cert;
    case precert_entry: PreCert;
} signed_entry;

PreCertは以下のように定義されています。

struct {
    opaque issuer_key_hash[32];
    BSCertificate tbs_certificate;
} PreCert;

issuer_key_hash はSCTを生成時に使った証明書の公開鍵のSHA256ハッシュ値になります。もう少し厳密には証明書内のSubjectPublicKeyInfoの部分を取り出してDERエンコードしたものをハッシュします。前回Fulcioにリクエストを送ったときに一緒に返ってきていました。ルート証明書ではなく中間証明書になります。

この証明書はサーバから取ってこれるので今回は再取得します。証明書は複数ありえますし失効によって異なる証明書が返ることもあるはずなので、実際にはFulcioから返ってきたものを使うべきです。

$ curl -O https://sigstore-tuf-root.storage.googleapis.com/targets/fulcio_intermediate_v1.crt.pem

この中間証明書の中身を見てみます。

$ openssl x509 -text -in fulcio_intermediate_v1.crt.pem -pubin -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            b9:d5:89:57:e7:53:46:eb:25:ab:26:46:41:eb:9f:f5:27:7d:a4
        Signature Algorithm: ecdsa-with-SHA384
        Issuer: O = sigstore.dev, CN = sigstore
        Validity
            Not Before: Apr 13 20:06:15 2022 GMT
            Not After : Oct  5 13:56:58 2031 GMT
        Subject: O = sigstore.dev, CN = sigstore-intermediate
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (384 bit)
                pub:
                    04:f1:15:52:ff:2b:07:f8:d3:af:b8:36:72:3c:86:
                    6d:8a:58:14:17:d3:65:6a:b6:29:01:df:47:3f:5b:
                    c1:04:7d:54:e4:25:7b:ec:b4:92:ee:cd:19:88:7e:
                    27:13:b1:ef:ee:9b:52:e8:bb:ef:47:f4:93:93:bf:
                    7c:2d:58:0c:cc:b9:49:e0:77:88:7c:5d:ed:1d:26:
                    9e:c4:b7:18:a5:20:12:af:59:12:d0:df:d1:80:12:
                    73:ff:d8:d6:0a:25:e7
                ASN1 OID: secp384r1
                NIST CURVE: P-384
        X509v3 extensions:
            X509v3 Key Usage: critical
                Certificate Sign, CRL Sign
            X509v3 Extended Key Usage:
                Code Signing
            X509v3 Basic Constraints: critical
                CA:TRUE, pathlen:0
            X509v3 Subject Key Identifier:
                DF:D3:E9:CF:56:24:11:96:F9:A8:D8:E9:28:55:A2:C6:2E:18:64:3F
            X509v3 Authority Key Identifier:
                58:C0:1E:5F:91:45:A5:66:A9:7A:CC:90:A1:93:22:D0:2A:C5:C5:FA
    Signature Algorithm: ecdsa-with-SHA384
    Signature Value:
        30:64:02:30:3c:2b:10:2b:80:d8:89:96:03:3c:86:83:8b:91:
        c5:2a:77:f1:5f:1e:80:49:25:66:11:17:ec:ca:76:01:89:7d:
        97:e9:22:51:9d:95:3c:e3:ff:43:65:d9:c5:be:fc:66:02:30:
        4e:b7:a4:29:06:57:38:27:fd:03:c6:f9:13:0a:aa:5c:96:fc:
        e2:2f:a0:42:08:f9:e3:76:52:01:dc:fb:b7:07:1b:0f:9b:28:
        14:63:b2:22:db:36:dd:09:d9:62:8a:8c

CN = sigstore-intermediate になっており中間証明書感があります。

このうち Subject Public Key Info だけ必要なので取り出します。

$ openssl x509 -in fulcio_intermediate_v1.crt.pem -pubin -pubkey -noout > fulcio_intermediate_v1.pub.pem

このままだとPEMになっているのでDERに変換します。上のコマンドでDERの変換まで一発でやる方法は見つけられなかったので、再度opensslコマンドでPEMからDERに変換します。

$ openssl ec -in fulcio_intermediate_v1.pub.pem -pubin -outform DER > fulcio_intermediate_v1.pub.der
read EC key
writing EC key

これでDER形式の公開鍵が手に入りました。これのSHA256ハッシュ値はあとで計算します。該当するコードは以下です。RawSubjectPublicKeyInfoを単にSHA256でハッシュ計算しているのが分かります。

certificate-transparency-go/serialization.go at 100db00880e6af80af0132c01b09c9eb2c4ee2dc · google/certificate-transparency-go · GitHub

tbs_certificate

こいつも結構厄介です。まずここにはDER形式のPrecertificateが入ります。ただし、Precertificateのために入れたpoison extensionと署名は除きます。

tbs_certificate" is the DER-encoded TBSCertificate (see [RFC5280]) component of the Precertificate -- that is, without the signature and the poison extension

さらにfinal certificateからSCTを消してもこの tbs_certificate が得られるとも書いています。多分署名も取り除く必要があると思うのですが、当たり前過ぎて書かれていないのかもしれません。

Note that it is also possible to reconstruct this TBSCertificate from the final certificate by extracting the TBSCertificate from it and deleting the SCT extension.

最終的にクライアントに返される証明書は既に持っているので、ここからSCTを消せば良さそうです。

残念なことにCLIでやる方法は見つけられなかったのでPythonのコードを書きました。CLIだけで完遂できず悔しいです。

from pyasn1.codec.der.decoder import decode as asn1_decode
from pyasn1.codec.der.encoder import encode as asn1_encode
from pyasn1_modules import rfc5280
from cryptography import x509
from cryptography.hazmat.backends import default_backend

with open("cert.pem", "rb") as f:
    cert = asn1_decode(x509.load_pem_x509_certificate(f.read(), default_backend()).tbs_certificate_bytes, asn1Spec=rfc5280.TBSCertificate())[0]

newExts = [ext for ext in cert["extensions"] if str(ext["extnID"]) != "1.3.6.1.4.1.11129.2.4.2"]
cert["extensions"].clear()
cert["extensions"].extend(newExts)

with open("cert-no-sct.der", "wb") as f:
    f.write(asn1_encode(cert))

Fulcioに発行してもらった cert.pem からSCTを削除したものを cert-no-sct.der に保存しています。 google/certificate-transparency-go のコードを読むと確かにSCTを削除しています。

certificate-transparency-go/serialization.go at 100db00880e6af80af0132c01b09c9eb2c4ee2dc · google/certificate-transparency-go · GitHub

また、この tbs_certificate は可変長であるため後ほどサイズが必要になります。

$ wc -c cert-no-sct.der
412 cert-no-sct.der
$ printf "%x\n" 412
19c

この 0x019C (=412 bytes) はあとで使います。

extensions

extensions" are future extensions to this protocol version (v1). Currently, no extensions are specified.

とのことなので今は0を埋めておけばよいです。

SCT署名対象の作成

ようやく全ての値を入手したので digitally-signed の元を作っていきます。エンコード方法はRFC5280に書いてありますが、以下の関数を見ると具体的に理解できると思います。

certificate-transparency-go/tls.go at d2e643e1a2f08235fcd8e1911a9d55c1b5b94cbc · google/certificate-transparency-go · GitHub

基本は上のフィールドから順番に固定長でビッグエンディアンで詰めていって、可変長なフィールドだけ最初に長さを入れるという簡単なエンコーディングです。

ということで手動で作ってみます。

# sct_version = v1 = 0x00, 1 byte
echo -n -e \\x00 > data.bin

# signature_type = certificate_timestamp = 0x00, 1 byte
echo -n -e \\x00 >> data.bin           

# timestamp = 0x18359e79054, 8 bytes
echo -n -e \\x00\\x00\\x01\\x83\\x59\\xe7\\x90\\x54 >> data.bin 

# entry_type = precert_entry = 0x0001, 2 bytes
echo -n -e \\x00\\x01 >> data.bin

# issuer_key_has, 32 bytes
cat fulcio_intermediate_v1.pub.der | openssl dgst -sha256 -binary >> data.bin

# length of DER-encoded cert, 3 bytes
echo -n -e \\x00\\x01\\x9c >> data.bin 

# DER-encoded cert without the SCT extension
cat cert-no-sct.der >> data.bin    

# extensions, none, 0-length, 2 bytes
echo -n -e \\x00\\x00 >> data.bin      

これで data.bin には署名元の値が入っています。あとは署名検証するだけです。簡単ですね。

というのは嘘で、これを組み立てるの非常に大変でした。

SCTの検証

検証のためにSCTの署名を取り出す必要があります。opensslコマンドでSCTの署名だけ綺麗に取り出す方法が分からなかったのでgrepでゴリ押しで行きます。

$ openssl x509 -in cert.pem -pubin -outform PEM -noout -text | grep -A5 "Signature : ecdsa-with-SHA256" | grep -v "Signature" | tr -d ":\n  "
30450220659B885C139E0EFCCE7F78D877CEE2C9DC34A63C8CB3A16874405324DD35436C022100C14DBBE17D4376DCC720B258D987FCEEC374A10EDE40CA8AA96758940A6AC191

このように署名だけ抜き出したあと、xxdを使って16進数で sct.sig にダンプしておきます。

$ echo -n 30450220659B885C139E0EFCCE7F78D877CEE2C9DC34A63C8CB3A16874405324DD35436C022100C14DBBE17D4376DCC720B258D987FCEEC374A10EDE40CA8AA96758940A6AC191 | xxd -r -p > sct.sig

では晴れて検証可能になったのでopensslコマンドで検証します。

$ openssl dgst -sha256 -verify ctfe.pub -signature sct.sig data.bin
Verified OK

CTログサーバの公開鍵(ctfe.pub)を使って、SCTに含まれていたタイムスタンプを元に作った data.bin の署名がSCT内の署名(sct.sig)と一致することを確かめ、無事にOKとなりました。

まとめ

手動でSCTの検証をしました。これが出来たから何なんだという感じなんですが、すぐ出来るだろうと始めた手前、途中から意地になって絶対に成功させてやるみたいになってしまいました。正直時間を無駄にした感じが否めませんが、技術者としてのプライドを守ったのでセーフ(?)です。

そして今回APIを一度も叩いていないのでcurlをほぼ使っていないことにお気付きでしょうか。タイトルに偽りありです。

curlでKeyless Signingする (2) - Fulcio編

はじめに

本記事はKeyless Signing連載の二本目です。

前回 はKeyless SigningのうちOpenID Connect(OIDC)によるIDトークンの発行までを行いました。この記事ではFulcioに証明書を発行してもらうところまで行います。

docs.sigstore.dev

Fulcioはコード署名のためのルート認証局(CA)という位置づけです。自分で作った公開鍵を投げると証明書を発行してくれます。ただしOIDCのIDトークンを要求するところが少しユニークな点です。このIDトークンにより誰が証明書を発行したのかを担保します。

Fulcioはコード署名のLet's Encryptを自称しているだけあってSSL/TLSサーバ証明書の発行のフローとよく似ています。

“The Let's Encrypt of Code Signing"

A Fulcio Deep Dive

証明書発行の流れ

まず始めにFulcioで証明書を発行するまでに行われることを見ていきます。以下のドキュメントに7つの手順があると書かれています。

fulcio/how-certificate-issuing-works.md at 2e9b552847e2d05121cfd6e92da66aa2f0651cdb · sigstore/fulcio · GitHub

  1. Certificate Request Input
  2. Authentication
  3. Verifying the challenge
  4. Constructing a certificate
  5. Signing the certificate
  6. Certificate Transparency log inclusion
  7. Return certificate to client

検証などを一旦忘れて大雑把にやることを列挙すると

  1. IDトークンと公開鍵をFulcioに送る
  2. FulcioがIDトークン内のメールアドレスなどを埋め込んで署名してPrecertificateを作る
  3. Certificate TransparencyログサーバにPrecertificateを送りSigned Certificate Timestamp (SCT)を得る
  4. SCTを証明書に埋め込んで再度署名してクライアントに返す

という流れです。PrecertificateやSCTも忘れてしまえば、"IDトークンと公開鍵を送ると証明書を返してくれる"サービスです。誰かが審査するとかはなくて、有効なIDトークンさえ送れば自動で証明書が発行されます。FulcioはOSSなので自前でホストすることも出来ますし、パブリックインスタンスfulcio.sigstore.dev )を使うことも出来ます。

以下でそれぞれのフローを詳しく見ていきますが、この部分はドキュメントを読めば分かることなので後半の手動で試すところまでスキップしても良いです。

Certificate Request Input

Fulcioのドキュメントより引用

クライアントは以下の3つをFulcioに送ります。

  • OIDC IDトーク
  • 公開鍵
  • チャレンジ

まずIDトークンを含めます。 前回の記事 では主にメールアドレスを署名者のIDとして用いていましたが、GitHub ActionsなどのIdPに対応しているCI/CD上でIDトークンを発行する場合はworkflow identityも利用可能です。GitHub Actionsの場合はworkflowを定義しているYAMLファイルのファイルパスになります( .github/workflows/release.yaml など)。

公開鍵は秘密鍵と共に手元で作ったものです。

そして概要の説明時にはIDトークンと公開鍵を送ると言いましたが、実際にはもう一つチャレンジというものを送ります。OIDCトークンの sub クレームの値を秘密鍵で署名したものになります。このチャレンジの署名検証によって公開鍵の正当な保持者であることが証明できます。と言っておいてなんですが、これは実は嘘です。

A signed challenge. This challenge proves the client is in possession of the private key that corresponds to the public key provided. The challenge is created by signing the subject (sub) of the OIDC identity token.

確かにドキュメントにも上のように書いてあるのですが、実際にIDトークンの中を見ると sub にはBase64エンコードされた文字列が入っており、メールアドレスは実際には email に入っています。自分が何か勘違いしているのかと思いソースコードを読みましたが、 subemail がない場合だけ使われるようになっていました。

sigstore/flow.go at 390675bb3335540929df00e05fdd01ce50b7224e · sigstore/sigstore · GitHub

やはりソースコードが正義ということです。ドキュメントを常に疑ってかかる姿勢を大事にしましょう。

これらの代わりにCertificate Signing Request (CSR) を送ることも出来るようですが、今回は上の3つを送ります。あとで実際にやってみます。

Authentication

ここから先はFulcio内部で行われることです。

Fulcioのドキュメントより引用

次に受け取ったIDトークンの認証を行います。これは 前回の記事 で手元で行ったIDトークンの認証と同じことをFulcioで行うだけです。正しいIssuer(iss) によって発行されていることをまず確認し、次にIdPの公開鍵を使って署名検証をします。これで署名を行った人間・またはworkflowの身元確認が出来ます。

Verifying the challenge

Fulcioのドキュメントより引用

リクエスト内に含まれていたチャレンジの検証を行います。チャレンジを公開鍵で復号し、IDトークン内の sub または email クレームのハッシュ値と一致するかを確認します。これで秘密鍵を持っている=正しい公開鍵の保持者であることが確認できます。

Constructing a certificate

Fulcioのドキュメントより引用

そして証明書を作ります。IDトークン内のisssub (または email )や公開鍵を埋め込みます。他にもGitHub ActionsのworkflowのメタデータもIDトークン内に存在する場合はX.509の拡張領域に入れます。

Signing the certificate

Fulcioのドキュメントより引用

Fulcioの秘密鍵を使ってこの証明書に署名をします。署名のバックエンドとしてKMSやTinkなどが選べます。

Certificate Transparency log inclusion

Fulcioのドキュメントより引用

証明書を発行したらCertificate Transparency (CT) ログサーバに証明書を追加します。このCTログサーバは改ざんできないようになっています。CTログサーバに証明書をアップロードする際、"Poison"というX.509拡張を入れることで一般的には使えない証明書にしています。これをPrecertificateと呼んでいます。このフローはSSL/TLSサーバ証明書と同じなので、Certificate Transparencyについて調べてみるとFulcioにおけるCTも理解できると思います。

そしてCTログサーバからSigned Certificate Timestamp (SCT)というものが返されます。このSCTの検証だけで長くなったので次の記事で説明します。

Fulcioのドキュメントより引用

このSCTはCTログが保存されたということの証明なので、これを証明書に埋め込みます。SCTを埋め込んだことで証明書のハッシュ値が変わってしまうので再度署名をし直します。つまりCTログに追加するためのPrecertificateとSCTを埋め込んだあとの証明書の2つあるということです。この辺も通常のCTログと同じです。

ドキュメントに書いてありますが、このFulcioのCTログはRekorとは別に保存されています。FulcioのCTログは発行された証明書だけ保存するのに対し、Rekorは署名やattestationも保存するためもう少し汎用的です。

Return certificate to client

Fulcioのドキュメントより引用

あとはIDトークンの情報や公開鍵、そしてSCTを含んだ証明書をクライアントに返します。

手動で発行する

ここまででFulcioの挙動は何となく理解できたと思います。ここからは実際に手で証明書の発行を行ってみます。

Certificate Request Input

手元で鍵ペアを作り、IDトークンと一緒にFulcioに投げます。さらに先程説明したようにメールアドレス(または sub )を秘密鍵で署名した値をチャレンジとして一緒に投げます。ということで以下の3つを生成していきます。

IDトーク

これは 前回の記事 で発行したので参照してください。

鍵ペアの作成

Cosignのソースコードを覗くとECDSAを使って秘密鍵を生成しているのが分かります。

cosign/keys.go at 7ba521444f9fcfdf2e1e5936c05834597674e6c9 · sigstore/cosign · GitHub

ということでopensslコマンドで秘密鍵を生成します。

$ openssl ecparam -genkey -name prime256v1 -outform PEM -out key.pem 

そして公開鍵を生成します。Cosignでは公開鍵に対して x509.MarshalPKIXPublicKey を呼んでいます。

cosign/fulcio.go at 7ba521444f9fcfdf2e1e5936c05834597674e6c9 · sigstore/cosign · GitHub

この関数はDER形式に変換するようです。

x509 package - crypto/x509 - Go Packages

ということでDER形式にした公開鍵を生成します(-outform DER)。

$ openssl ec -in key.pem -pubout -outform DER -out pubkey.der
read EC key
writing EC key

これで鍵ペアが手に入りました。

チャレンジの作成

今回はIDトークンを自分のメールアドレスで発行したので、IDトークン内の sub ではなく email クレームを使います。メールアドレスをSHA256でハッシュ計算しその値に対して先ほど作成した秘密鍵で署名します。Cosignでは以下の箇所でメールアドレスに対する署名が行われています。

cosign/fulcio.go at 7ba521444f9fcfdf2e1e5936c05834597674e6c9 · sigstore/cosign · GitHub

IDトークンのemailknqyf263@gmail.com に署名してみます。自分でハッシュ計算して署名してもよいのですが、opensslで一発で行えます。ASN.1でエンコードされた値が返ってくるのでBase64エンコードします。

$ echo -n "knqyf263@gmail.com" | openssl dgst -sha256 -sign key.pem | base64 -w0 > email.sig

# 一応 asn1parse して確認
$ base64 -d email.sig | openssl asn1parse -inform der
    0:d=0  hl=2 l=  70 cons: SEQUENCE
    2:d=1  hl=2 l=  33 prim: INTEGER           :DA2CF1A68EA1609999AF922E15E4BF23E72E425731A7D73B112E46FE66262D8E
   37:d=1  hl=2 l=  33 prim: INTEGER           :A58C320026FAE4EC6D9B563FF66B361CC234882EABE47CA641647D8B74113965

これでチャレンジの完成です。

リクエストの作成

上で作ったメールアドレスの署名と公開鍵をJSONに入れます。公開鍵は publicKeycontent に入れて algorighm も指定します。チャレンジは signedEmailAddress に入れます。

cosign/fulcio.go at 7ba521444f9fcfdf2e1e5936c05834597674e6c9 · sigstore/cosign · GitHub

jqとかで賢くできそうですが、ここでは力技で作ります。

$ echo "{
    \"publicKey\": {
        \"content\": \"$(base64 -w0 pubkey.der)\",
        \"algorithm\": \"ecdsa\"
    },
    \"signedEmailAddress\": \"$(cat email.sig)\"
}" > req.json

完成形は以下です。

{
  "publicKey": {
    "content": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE/5L2Un/ibLlDCUSsu80/yzoTpsy0rRaaBTN/kPyemVEo6jasVBVJtGieY41kDu7oODiEn0gG69N7Ci2JLiQJJQ==",
    "algorithm": "ecdsa"
  },
  "signedEmailAddress": "MEYCIQDaLPGmjqFgmZmvki4V5L8j5y5CVzGn1zsRLkb+ZiYtjgIhAKWMMgAm+uTsbZtWP/ZrNhzCNIguq+R8pkFkfYt0ETll"
}

リクエストをFulcioに送る

JSONが出来上がったのでFulcioにIDトークンと一緒に投げます。ちなみにIDトークンの有効期限は59秒なので、ここまでゆっくり手で確かめてきた場合はほぼ間違いなく有効期限が切れているので再発行します。認可コードも再取得が必要です。前回の記事 を参考に再発行してください。今回は取得したJWTを jwt.json に書き出しています。

$ curl -X POST https://oauth2.sigstore.dev/auth/token -H "Authorization: Basic c2lnc3RvcmU6" -d "code=for6hqtcd7vy7ypsb3hmbfbzi&code_verifier=ir5shaejohr1piu8eicei2aipieMeej3al6ou9Chies&grant_type=authorization_code&nonce=nonce&redirect_uri=http%3A%2F%2Flocalhost%3A60000%2Fauth%2Fcallback" | jq -r .id_token > token.jwt

ではリクエストを送ります。59秒以内に行う必要があるのでここは時間との勝負です。絶対に自動化せずに全部手でやってやるという強い意志が必要です。IDトークンは Authorization ヘッダに入れています。

$ curl -X POST https://fulcio.sigstore.dev/api/v1/signingCert \
  -H "Authorization: Bearer $(cat token.jwt)" \
  -H "Content-Type: application/json" \
  -d @req.json

-----BEGIN CERTIFICATE-----
MIICoDCCAiegAwIBAgIUZgHjBZrbK3kC14OpgRVvC4yOX4AwCgYIKoZIzj0EAwMw
NzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRl
cm1lZGlhdGUwHhcNMjIwOTIwMDc1NzM4WhcNMjIwOTIwMDgwNzM4WjAAMFkwEwYH
KoZIzj0CAQYIKoZIzj0DAQcDQgAE/5L2Un/ibLlDCUSsu80/yzoTpsy0rRaaBTN/
kPyemVEo6jasVBVJtGieY41kDu7oODiEn0gG69N7Ci2JLiQJJaOCAUYwggFCMA4G
A1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUIZZM
GQRZVtbjt0Ndr01jlyYbifgwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4Y
ZD8wIAYDVR0RAQH/BBYwFIESa25xeWYyNjNAZ21haWwuY29tMCwGCisGAQQBg78w
AQEEHmh0dHBzOi8vZ2l0aHViLmNvbS9sb2dpbi9vYXV0aDCBigYKKwYBBAHWeQIE
AgR8BHoAeAB2AAhgkvAoUv9oRdHRayeEnEVnGKwWPcM40m3mvCIGNm9yAAABg1nn
Da4AAAQDAEcwRQIhANKNWBHWXaZ0b5quoyflm5HxkOjQp9bHqrWi+Og7sObcAiBo
lf691xSgqCKQ4LgWpP9bpR5eq2K+SMbSuuWONlVmbTAKBggqhkjOPQQDAwNnADBk
AjBwCAIBzOjVJNbF0esc8RqUIMw9t+ISCgaTM2xSoQo+2qvzoFjbas7FnXR0Wv2L
VJcCMHwvUWwSj2DQyLHe8dDFOMSCy2VLMrzzZVk5WyIkq6jteQ95r29/Ik3Oe897
B9Y3uw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMw
KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y
MjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3Jl
LmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0C
AQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV7
7LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS
0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYB
BQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjp
KFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZI
zj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJR
nZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsP
mygUY7Ii2zbdCdliiow=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw
KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y
MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl
LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7
XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex
X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j
YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY
wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ
KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM
WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9
TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ
-----END CERTIFICATE-----

ということで証明書を発行できました。

証明書の中身確認

3つ返ってきたうちの一番上の証明書がユーザの公開鍵に対応した証明書なので、ファイル( cert.pem )に書き出し中身を見てみます。

$ openssl x509 -in cert.pem -pubin -outform PEM -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            66:01:e3:05:9a:db:2b:79:02:d7:83:a9:81:15:6f:0b:8c:8e:5f:80
        Signature Algorithm: ecdsa-with-SHA384
        Issuer: O = sigstore.dev, CN = sigstore-intermediate
        Validity
            Not Before: Sep 20 07:57:38 2022 GMT
            Not After : Sep 20 08:07:38 2022 GMT
        Subject:
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub:
                    04:ff:92:f6:52:7f:e2:6c:b9:43:09:44:ac:bb:cd:
                    3f:cb:3a:13:a6:cc:b4:ad:16:9a:05:33:7f:90:fc:
                    9e:99:51:28:ea:36:ac:54:15:49:b4:68:9e:63:8d:
                    64:0e:ee:e8:38:38:84:9f:48:06:eb:d3:7b:0a:2d:
                    89:2e:24:09:25
                ASN1 OID: prime256v1
                NIST CURVE: P-256
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature
            X509v3 Extended Key Usage:
                Code Signing
            X509v3 Subject Key Identifier:
                21:96:4C:19:04:59:56:D6:E3:B7:43:5D:AF:4D:63:97:26:1B:89:F8
            X509v3 Authority Key Identifier:
                DF:D3:E9:CF:56:24:11:96:F9:A8:D8:E9:28:55:A2:C6:2E:18:64:3F
            X509v3 Subject Alternative Name: critical
                email:knqyf263@gmail.com
            1.3.6.1.4.1.57264.1.1:
                https://github.com/login/oauth
            CT Precertificate SCTs:
                Signed Certificate Timestamp:
                    Version   : v1 (0x0)
                    Log ID    : 08:60:92:F0:28:52:FF:68:45:D1:D1:6B:27:84:9C:45:
                                67:18:AC:16:3D:C3:38:D2:6D:E6:BC:22:06:36:6F:72
                    Timestamp : Sep 20 07:57:38.094 2022 GMT
                    Extensions: none
                    Signature : ecdsa-with-SHA256
                                30:45:02:21:00:D2:8D:58:11:D6:5D:A6:74:6F:9A:AE:
                                A3:27:E5:9B:91:F1:90:E8:D0:A7:D6:C7:AA:B5:A2:F8:
                                E8:3B:B0:E6:DC:02:20:68:95:FE:BD:D7:14:A0:A8:22:
                                90:E0:B8:16:A4:FF:5B:A5:1E:5E:AB:62:BE:48:C6:D2:
                                BA:E5:8E:36:55:66:6D
    Signature Algorithm: ecdsa-with-SHA384
    Signature Value:
        30:64:02:30:70:08:02:01:cc:e8:d5:24:d6:c5:d1:eb:1c:f1:
        1a:94:20:cc:3d:b7:e2:12:0a:06:93:33:6c:52:a1:0a:3e:da:
        ab:f3:a0:58:db:6a:ce:c5:9d:74:74:5a:fd:8b:54:97:02:30:
        7c:2f:51:6c:12:8f:60:d0:c8:b1:de:f1:d0:c5:38:c4:82:cb:
        65:4b:32:bc:f3:65:59:39:5b:22:24:ab:a8:ed:79:0f:79:af:
        6f:7f:22:4d:ce:7b:cf:7b:07:d6:37:bb

Subject Alternative Name (SAN) にはIDトークン内のメールアドレスが含まれています。

X509v3 Subject Alternative Name: critical
     email:knqyf263@gmail.com

1.3.6.1.4.1.57264.1 で始まるOIDはFulcioのものとして登録されているようで、GitHubのWorkflowに関するメタデータなども入れられるようになっています。

fulcio/oid-info.md at main · sigstore/fulcio · GitHub

1.3.6.1.4.1.57264.1.1 はIssuerとして定義されています。IDトークンの iss を入れただけかと思いきや、よくIDトークンを見返すと iss は以下になっています。

  "iss": "https://oauth2.sigstore.dev/auth",

ですが上のIssuerはGitHubになっており、異なる値です。どうやら iss ではなくIDトークン内の federated_claims.connector_id を使っているようです。これは元々のGitHubのIDトークンの iss の値と一致するはずです。IdPとしてGoogleを使えば https://accounts.google.com になります。ちなみにこの federated_claims については仕様が見つけられなかったのですがsigstore独自のクレームなのでしょうか。

  "federated_claims": {
    "connector_id": "https://github.com/login/oauth",
    "user_id": "2253692"
  }

そして上述したSigned Certificate Timestamp (SCT)も含まれています。SCTについてはこの次の記事で細かく解説します。

            CT Precertificate SCTs:
                Signed Certificate Timestamp:
                    Version   : v1 (0x0)
                    Log ID    : 08:60:92:F0:28:52:FF:68:45:D1:D1:6B:27:84:9C:45:
                                67:18:AC:16:3D:C3:38:D2:6D:E6:BC:22:06:36:6F:72
                    Timestamp : Sep 20 07:57:38.094 2022 GMT
                    Extensions: none
                    Signature : ecdsa-with-SHA256
                                30:45:02:21:00:D2:8D:58:11:D6:5D:A6:74:6F:9A:AE:
                                A3:27:E5:9B:91:F1:90:E8:D0:A7:D6:C7:AA:B5:A2:F8:
                                E8:3B:B0:E6:DC:02:20:68:95:FE:BD:D7:14:A0:A8:22:
                                90:E0:B8:16:A4:FF:5B:A5:1E:5E:AB:62:BE:48:C6:D2:
                                BA:E5:8E:36:55:66:6D

二番目と三番目の証明書はそれぞれFulcioの中間証明書とルート証明書です。発行してもらった証明書はこれらとチェーンになっています。@otameshi61さんのブログ でも証明書の検証をしていますが、一応ここでも同様の内容を書いておきます。

まず上の2つの証明書をCAファイルに書き出します。リモートからも取ってこれます。

$ curl https://sigstore-tuf-root.storage.googleapis.com/targets/fulcio_v1.crt.pem > p.crt.pem
$ echo >> p.crt.pem
$ curl https://sigstore-tuf-root.storage.googleapis.com/targets/fulcio_intermediate_v1.crt.pem >> p.crt.pem

そしてこれらによって証明書チェーンが作られていることを確認します。

$ openssl verify -verbose -CAfile  p.crt.pem cert.pem
cert.pem:
error 10 at 0 depth lookup:certificate has expired
OK

確かにFulcioによって発行された証明書のようです。

Authentication

ここまでで証明書は発行できたのですが、Fulcioで行っていることも一応確認していきます。 まずAuthenticationはIDトークンの検証ですが、 前回の記事 で検証したのと同じ手順なので省略します。

Verifying the challenge

Fulcioでは送られてきたチャレンジを、一緒に送られてきた公開鍵で検証します。

# まずBase64デコードする
$ cat email.sig | base64 -d > email.sig.bin

# 検証する
$ echo -n "knqyf263@gmail.com" | openssl dgst -sha256 -verify pubkey.der -signature email.sig.bin
Verified OK

ということで検証終わりです。あとは証明書を作成してCTログに登録し、クライアントに証明書を返します。このフローの一部は次の記事で検証します。

まとめ

OIDCのIDトークンと手元で作成した公開鍵を使ってFulcioで証明書の発行ができました。このあとは秘密鍵を使ってソフトウェアに署名をし、その署名と証明書をRekorにアップロードします。RekorにアップロードさえしてしまえばFulcioから得た証明書は破棄可能なところが面白い点です。ですがその前に上のCTログサーバによって返されたSCTの検証を次の記事でやってみます。

curlでKeyless Signingする (1) - OpenID Connect編

確実に忘れるであろう将来の自分と、Keyless Signingに異常な興味を持つ日本に数人しかいないであろう人達のための記事です。

背景

以前sigstoreのソフトウェア署名についてブログを書きました。

knqyf263.hatenablog.com

その中でKeyless Signingについては別ブログにすると言っていたのですがサボり続けた結果、全て忘れ去り再び調べる羽目になりました。これはまた忘れるだろうなということで今回はちゃんと書いておきます。ただ概要ではなく理解を深めるために、sigstoreにより提供されているCosignコマンドを使わずに、自分でcurlなどのCLIを使って署名をしてみます。タイトルにはcurlで〜と書きましたが実際にはopensslなど他のコマンドのほうが多分多いです。

curlでやってみるシリーズは過去にいくつかあります。

curlでdocker pullをする - knqyf263's blog

curlで始めるDockerコンテナからの脱出 - knqyf263's blog

コンテナイメージのlazy pullingをcurlで試してみる - knqyf263's blog

Keyless Signingと言っても構成要素は既存技術を用いているので、Keyless Signingを通してOpenID ConnectやCertificate Transparencyへの理解を深めることが出来てお得です。ただ理解を深めるためとはいえCLIだけで頑張るのは少しやりすぎた気がします(疲れた)。

あまりに長くなりすぎたので6本立てにしています。

前提

@otameshi61 さんによる以下の素晴らしいブログをベースに書いているので、まず最初にこちらに目を通してください。

otameshi61.hatenablog.com

ソフトウェア署名については上の拙作ブログを参照してください。Cosign/Rekor/Fulcioって何?というところはそちらに書いてあるので今回は説明しません。

今回の一連のブログでは以下の処理をなぞる形になります。

$ COSIGN_EXPERIMENTAL=1 cosign sign-blob --output foo.txt.sig foo.txt -d -y

一度実行してみると何となく流れがわかると思います。 --output で署名をファイルに書き出しています。

Using payload from: foo.txt
Generating ephemeral keys...
Retrieving signed certificate...

        Note that there may be personally identifiable information associated with this signed artifact.
        This may include the email address associated with the account with which you authenticate.
        This information will be used for signing this artifact and will be stored in public transparency logs and cannot be removed later.
Your browser will now be opened to:
https://oauth2.sigstore.dev/auth/auth?access_type=online&client_id=sigstore&code_challenge=H13ag3T3yPmFwkMTSseirGjQ5tfKS2qikJ1AMRKK_v8&code_challenge_method=S256&nonce=2F7Fus8FmtjTIQDcqThsEFJcPc0&redirect_uri=http%3A%2F%2Flocalhost%3A61210%2Fauth%2Fcallback&response_type=code&scope=openid+email&state=2F7Fur6rgHNP72W6fTGrduNN4SF
Successfully verified SCT...
using ephemeral certificate:
-----BEGIN CERTIFICATE-----
MIICoTCCAiegAwIBAgIUJxUJH9K0n8Ny0QZPogrFANOE06MwCgYIKoZIzj0EAwMw
NzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRl
cm1lZGlhdGUwHhcNMjIwOTIyMDgzMDQ2WhcNMjIwOTIyMDg0MDQ2WjAAMFkwEwYH
KoZIzj0CAQYIKoZIzj0DAQcDQgAElNMrxlUpjhehAjo9v1F4mcrxReu7tNOzkUIW
27K3voNV/RMruAAuXVjc9BVqPPfYrJPMvGEnSztVe7zxrbP+4KOCAUYwggFCMA4G
A1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUbg1a
x9w/9jcbajdkJynERn9YcMEwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4Y
ZD8wIAYDVR0RAQH/BBYwFIESa25xeWYyNjNAZ21haWwuY29tMCwGCisGAQQBg78w
AQEEHmh0dHBzOi8vZ2l0aHViLmNvbS9sb2dpbi9vYXV0aDCBigYKKwYBBAHWeQIE
AgR8BHoAeAB2AAhgkvAoUv9oRdHRayeEnEVnGKwWPcM40m3mvCIGNm9yAAABg2RS
HOQAAAQDAEcwRQIgFtbSQlZKSlRtv5eMOvQQ0aMQnht/MnXRCKYTfN9EkdMCIQD6
lsaVDdYpd2Wo1Qe8v0jD0MJFRBSO05DSzVAF3Ep/qTAKBggqhkjOPQQDAwNoADBl
AjEArCuDiXb5RbFJGtCELNgRQBgbbWaawhZ08ey1N7Dt8NTjAYyTdi7x2WcV43dX
GIQnAjA9AnUNJEkBwABgZsdsZUB/lTc4UZeEFpTrB5s55ECbnG6MKRW3sZcnARzQ
cWE0bBk=
-----END CERTIFICATE-----

tlog entry created with index: 3732405
MEYCIQCoUlGiu9bPGMJcnFkl60s6T68sgRXNFHeHn9ZzrWMQbgIhANh5FutvBdewXjxhDv9L8uTJ/RBXdhJsRtJc7Dbwz3pt

コンテナイメージの署名の話を混ぜるとややこしくなるので今回は単なるファイルやバイナリへの署名( cosign sign-blob )について見ていきます。こっちが理解できればコンテナイメージの署名もすぐ理解できると思います。

Cosignの処理をなぞると言いましたが、ソースコードを全て解説するのは大変すぎるので重要な部分だけに絞ります。

あとPKIって何?とかOpenID Connectって何?とかそういう基礎的なところも説明しません。

Keyless Signing全体のフロー

Keyless Signingの流れはざっくり以下です。

  1. OpenID Connectで署名者のIDトークンを取得
  2. 鍵ペアを生成し、ルート認証局であるFulcioで署名してもらって証明書を得る
    • このとき1で作ったIDトークンも一緒に送る
  3. 2で作った秘密鍵を使ってソフトウェア等に署名する
  4. Rekorに署名と証明書を保存する
  5. (2で作った鍵ペアや証明書を破棄する)

Cosign CLI を使ってKeyless Signingを使う場合のフローを図にすると以下です。

まず概要を掴むということで細かいフローを省いているので厳密でない箇所が複数あります。各要素の詳細な内容に関しては今後深堀りしていくので、この図はあくまで雰囲気を理解するためのものという点だけ注意してください。

このKeyless Signingにはいくつか特徴的な点があります。

  1. OpenID Connectで署名者の認証を行う
  2. 有効期限の短いIDトークンや証明書を使う
  3. 鍵ペアを保存しておく必要がない

Fulcioで証明書を発行する際、IDトークンも同時に送ることで誰が署名したのか?というのを検証します。その署名者のアイデンティティ(メールアドレスなど)は証明書のX.509拡張に含まれ改ざんできないようになります。

また、この生成された鍵ペアは1回使ったらすぐ破棄します。つまりソフトウェアに署名するときは毎回異なる鍵ペアが使われるということです。ソフトウェアのメンテナが鍵を保管する必要がないというのがKeylessと呼ばれる所以です。内部では鍵を使っています。

さらに証明書の有効期限が短く設定されているため、攻撃者に鍵ペアや証明書が後ほど盗まれても再度署名することは出来ません。これはRekorに登録されたTransparency Logの時間を見るのですが、細かい話なので別の記事にします。IDトークンの有効期限も短いので攻撃者がIDトークンを盗んで再度Fulcioで証明書を発行しようとしても弾かれます。

とにかく全てを使い捨てにして証明書や署名のログをRekorに残しておくことで鍵の管理を不要にしようというのがKeyless Signingの基本アイディアかなと思います。概要だけ知りたい人はここまでで十分かもしれません。

この記事では上記のうちOpenID Connect(OIDC)のフローを手動で確かめます。

OIDCのフロー

上では簡単にOIDCでIDトークンを取得、と書きましたが細かくは2つのステップがあります。

  1. 認可コードの取得
  2. IDトークンの取得

これは普通のOIDCのCode Grantフローなので、そもそもOIDCって何?という場合は以下などを見ると理解できると思います。

qiita.com

qiita.com

認可コードの取得

Cosign CLIによる認可コードの取得部分の詳細は以下のようになっています。少しややこしいのはアイデンティティプロバイダ(IdP)が2つ登場する点です。こういうIdPのリレーというかプロキシみたいなものは一般的なのかよく分かりませんでした。有識者によるコメントをお待ちしています。

  1. IdPである oauth2.sigstore.dev のAuthorization Endpointにアクセス
  2. GitHub/Google/Microsoftの中からIdPを選びリダイレクトされる
  3. ユーザが選択したIdPで認証・認可を行う
  4. 選択したIdPから認可コードがコールバックで返ってくるので oauth2.sigstore.dev に送る
  5. oauth2.sigstore.dev が認可コードを生成する
  6. oauth2.sigstore.devの認可コードが手元にコールバックで返ってくる

上の図ではGitHubをIdPとして選択した例になっています。図の2と4は oauth2.sigstore.dev とGitHubが直接通信しているように見えますが、実際にはリダイレクトなのでCosignを経由します。リダイレクトを厳密に書くと図が爆発したので上のように書いていますが、脳内で置換してください。

4で返ってくる認可コードはGitHubのものです。そしてそれを oauth2.sigstore.dev に渡すと oauth2.sigstore.dev の認可コードが生成されてコールバックで手元に返ってきます。実際に自分で使うのは oauth2.sigstore.dev の認可コードだけなので、GitHubの認可コードについては気にする必要はないです。

また、sigstoreのIdPではRFC 7636で定義されているPKCEに対応しています。そのため code_challengecode_verifier というパラメータをリクエストに含める必要があります。この点については後ほどリクエストの生成時に解説します。

IDトークンの取得

認可コードが手に入ったのでこれを使ってToken Endopointに問い合わせIDトークンを取得します。

先程同様、2はGitHubのIDトークンで5は oauth2.sitstore.dev のIDトークンであるという点がややこしいぐらいで、あとは特に複雑なところはないかと思います。

認可コードの取得時と違ってリダイレクトはなかったので oauth2.sigstore.dev が直接GitHubのToken Endpointを叩いていると推察しています。上の図では2にしていますが、どのタイミングでGitHubへのリクエストが発行されているかはわからないです。

手動で試す

@otameshi61さんのブログと重複する部分もありますが、理解を深めるために一通り手動でやっていきます。

OpenIDプロバイダーの情報取得

.well-known/openid-configurationOpenIDプロバイダー(IdP)の情報が取得可能なので最初に叩いておきます。このエンドポイントが生えていることからもoauth2.sigstore.dev がIdPになっていることが分かります。

$ curl https://oauth2.sigstore.dev/auth/.well-known/openid-configuration
{
  "issuer": "https://oauth2.sigstore.dev/auth",
  "authorization_endpoint": "https://oauth2.sigstore.dev/auth/auth",
  "token_endpoint": "https://oauth2.sigstore.dev/auth/token",
  "jwks_uri": "https://oauth2.sigstore.dev/auth/keys",
  "userinfo_endpoint": "https://oauth2.sigstore.dev/auth/userinfo",
  "device_authorization_endpoint": "https://oauth2.sigstore.dev/auth/device/code",
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:device_code"
  ],
  "response_types_supported": [
    "code"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "code_challenge_methods_supported": [
    "S256",
    "plain"
  ],
  "scopes_supported": [
    "openid",
    "email",
    "groups",
    "profile",
    "offline_access"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post"
  ],
  "claims_supported": [
    "iss",
    "sub",
    "aud",
    "iat",
    "exp",
    "email",
    "email_verified",
    "locale",
    "name",
    "preferred_username",
    "at_hash"
  ]
}

認可コードの取得

Authorization Endpointの仕様については詳しく説明しません。ただし先程述べたようにPKCEのために code_verifiercode_challenge を送る必要があります。詳しくは以下に書かれています。

qiita.com

code_verifierの生成

Authorization Endpointにリクエストを投げるときに code_verifier が必要なのでまず適当に生成します。43〜128文字なら良いようなので43文字にします。ランダム文字列を作るために pwgen というコマンドを使っていますが、 opessnl rand でも何でも良いです。

$ pwgen 43 1
ir5shaejohr1piu8eicei2aipieMeej3al6ou9Chies

code_challengeの生成

次に code_challenge を生成します。PKCEの仕様として策定されている RFC 7636 によると以下のようなシンプルな方法で code_challenge は生成可能とのことです。

code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

まずSHA256でハッシュ値を計算します。

$ echo -n | sha256sum -
dc4db41f5d3f59d43ca16cd19498b6f7827bfa908336dfa0d0834b118d6fd96a

これを16進数表記の文字列として扱い、Base64エンコードします。

$ echo -n 'dc4db41f5d3f59d43ca16cd19498b6f7827bfa908336dfa0d0834b118d6fd96a' | xxd -r -p | base64

URLとして扱えるように = を削除, + => -, / => _ などの置換を行います。

$ echo -n '3E20H10/WdQ8oWzRlJi294J7+pCDNt+g0INLEY1v2Wo=' | sed -e 's/=//g' | sed -e 's/+/-/g' | sed -e 's/\//_/g'
3E20H10_WdQ8oWzRlJi294J7-pCDNt-g0INLEY1v2Wo

ということで code_challenge を得ました。

一気にやりたければ以下のような感じ。

$ echo -n 'ir5shaejohr1piu8eicei2aipieMeej3al6ou9Chies' | sha256sum - | cut -d " " -f 1 | xxd -r -p | base64 | sed -e 's/=//g' | sed -e 's/+/-/g' | sed -e 's/\//_/g'
3E20H10_WdQ8oWzRlJi294J7-pCDNt-g0INLEY1v2Wo

以下を参考にしました。

PKCEにおけるcode_challenge生成について - Qiita

Authorization Endpointへのアクセス

今回はただ試したいだけなのでstateとnonceは適当に指定してます。実運用するときは当然ランダムな値にしてください。

$ open "https://oauth2.sigstore.dev/auth/auth?access_type=online&client_id=sigstore&code_challenge=3E20H10_WdQ8oWzRlJi294J7-pCDNt-g0INLEY1v2Wo&code_challenge_method=S256&nonce=nonce&redirect_uri=http%3A%2F%2Flocalhost%3A60000%2Fauth%2Fcallback&response_type=code&scope=openid+email&state=state"

open コマンドを使いましたが普通にブラウザで上記URLにアクセスすれば良いです。 code_challenge に先ほど生成した値を入れています。

また、 redirect_uri として http://localhost:60000 を指定しています。ここにcodeが飛んでくるので別ターミナルを開いて適当にncなどで待ち構えておきます。

$ nc -kl 60000

この oauth2.sigstore.dev もIdPとして動いていますが、上で説明したように実態は他のIdPにリクエストを送りIDトークンを取得後に再度sigstoreのIDトークンを生成しているだけに見えます。

ブラウザで上記のリンクを開くと以下の画面になります。GitHub, Google, MicrosoftのIdPに対応している事がわかります。

"Login with GitHub"をクリックした場合の挙動をcurlで確かめておきます。

$ curl "https://oauth2.sigstore.dev/auth/auth/https:%252F%252Fgithub.com%252Flogin%252Foauth?access_type=online&client_id=sigstore&code_challenge=3E20H10_WdQ8oWzRlJi294J7-pCDNt-g0INLEY1v2Wo&code_challenge_method=S256&nonce=nonce&redirect_uri=http%3A%2F%2Flocalhost%3A60000%2Fauth%2Fcallback&response_type=code&scope=openid+email&state=state"
<a href="https://github.com/login/oauth/authorize?client_id=e8bef66f2cde64c23f47&amp;redirect_uri=https%3A%2F%2Foauth2.sigstore.dev%2Fauth%2Fcallback&amp;response_type=code&amp;scope=user%3Aemail&amp;state=zkmdz6gtmla2yymmriekppt7z">Found</a>.

GitHubにリダイレクトされました。 redirect_urloauth2.sigstore.dev になっていてGitHubが認可コードを生成したら oauth2.sigstore.dev にコールバックされます。そして oauth2.sigstore.dev がさらに生成した認可コードが最終的にクライアントにコールバックされます。

curlGitHubの認証しても良いですが、ここは少しサボってブラウザから上記のGitHubのリンクにアクセスします。 "Authorize"ボタンを押すとlocalhostに認可コードが飛んできます。

$ nc -kl 60000
GET /auth/callback?code=d4bfn24c7bepxlz6c2u4sa2ev&state=state HTTP/1.1
Host: localhost:60000
Connection: keep-alive
Upgrade-Insecure-Requests: 1

今回だと d4bfn24c7bepxlz6c2u4sa2ev です。これはIDトークンの取得で一度使うと無効になります。

IDトークンの取得

認可コードが手に入ったので、IDトークンを取得します。基本的には認可コードをToken Endpointに投げるだけです。Basic認証が必要なようなので Authorization ヘッダを足しています。ユーザ名が sigstore でパスワードは空のようです。

$ echo -n c2lnc3RvcmU6 | base64 -d
sigstore:

code_verifier には最初に計算した値を入れます。 nonce は相変わらず適当にしています。

$ curl -X POST https://oauth2.sigstore.dev/auth/token -H "Authorization: Basic c2lnc3RvcmU6" -d "code=bp7gnogke3ea5nqtjwjeotg2t&code_verifier=ir5shaejohr1piu8eicei2aipieMeej3al6ou9Chies&grant_type=authorization_code&nonce=nonce&redirect_uri=http%3A%2F%2Flocalhost%3A60000%2Fauth%2Fcallback"

{"access_token":"eyJ...","token_type":"bearer","expires_in":59,"id_token":"eyJ..."}

ということでIDトークンが手に入りました。 expires_in が59なので有効期限はとても短くなっています。このIDトークンは適当にファイルに保存しておきます。ちなみに上述したように認可コードは一度使って無効になっているので再度curlすると失敗します。

$ echo -n '{"access_token":"eyJ...","token_type":"bearer","expires_in":59,"id_token":"eyJ..."}' > token.jwt

IDトークンの中身は以下のようになっています。IDトークンはヘッダ・ペイロード・署名で構成されておりそれぞれBase64エンコードしたものを . で連結しただけなのでデコードすれば値が見られます。

■ ヘッダ

{
  "alg": "RS256",
  "kid": "aeefb6c2fc062f48cfa510e209e0493fbff8e596"
}

ペイロード

{
  "iss": "https://oauth2.sigstore.dev/auth",
  "sub": "CgcyMjUzNjkyEiZodHRwczolMkYlMkZnaXRodWIuY29tJTJGbG9naW4lMkZvYXV0aA",
  "aud": "sigstore",
  "exp": 1663697876,
  "iat": 1663697816,
  "nonce": "nonce",
  "at_hash": "60yD_C7sKjaUYa4VBv3_4g",
  "c_hash": "bqK-Q6w9EaY9CNQ6-Dk4Sw",
  "email": "knqyf263@gmail.com",
  "email_verified": true,
  "federated_claims": {
    "connector_id": "https://github.com/login/oauth",
    "user_id": "2253692"
  }
}

■ 署名

peVaJbzFWmzG4ChHu0j1l_2uM9WLcnxmTdPxy9H_b1sFIcjks22Ij4GoTRF6pWGopVJyEE4Cx3Hl6woYDIix92KKXfCPZeimcaXO8y8B8uQUu5PyUTbt5z-hpipdY88Fgj3YGcLnl7Kh5j2aMXJt-f5ETOUdvVjwU4kstFCXs69yy60mPtBO5NdxF4m8Qi4jr2CjZ33hfdUm6jblf5Z3Jvk1KKHq3gXhrh6ZtEiD_fr007GQNKcLHw86uk8xDNsWIJNbmlpUKoutuMEzav0Qxsu8r8Q-x2kKQgNJ1oB7zS3eT8r4lvn0W8uCF0Kz9o-xRU-0KeqG3bFFzX1nyGDoIQ==

IDトークンの検証

せっかくなので入手したIDトークンの検証もしてみます。正直これに関しては自分でも何でこんなこと手動でやってんの?と思いながらやってました。調べても他にやってる人全然見つからなくて手探りでやりました。

まず isshttps://oauth2.sigstore.dev/auth であり、 audsigstore になっていることを確かめます。ここでは目視で終わりとします。

公開鍵の取得

次に署名を検証します。最初に見た .well-known/openid-configuration 内の jwks_uri で公開鍵を取得可能なので、まず公開鍵一覧を取得してみます。

$ curl https://oauth2.sigstore.dev/auth/keys
{
  "keys": [
    {
      "use": "sig",
      "kty": "RSA",
      "kid": "aeefb6c2fc062f48cfa510e209e0493fbff8e596",
      "alg": "RS256",
      "n": "1KYfnZ9qUVwg-GVbWe_Idj-OomW5vflcwUvfLYLW6feWfo3oXK-9GYa0AMaMJZWTP888b35Gl9QbU8wIY2A-IAsz0S-0xtbdynL-KkAt6jGdDGqpVgdDj5FltVlh43Qt3m48tSBDfyCJATOicpNrDeEhPw9KOIvS2MK_1K67jfkFTc9oKdL_Viqeej-BP3tA2O07-BlA5R1xhDY0Ia2GiIJv3MyjIuqWtZxQl1L4yYyTKZXnZ9fN9TPrWhtW4vKEUzG2cnkbiqMmlolzq8GNMln0DPyPen6u45VlFQ1RP9NLwWPPpXa2VwdCM-Lk0FtnWB_fghHXZQhdbRvtkJL8Sw",
      "e": "AQAB"
    },
    {
      "use": "sig",
      "kty": "RSA",
      "kid": "43bcf44f53aa199319ec058910182488dbd11058",
      "alg": "RS256",
      "n": "xdV0wU-If4svTGnO1dyDpm0ESfLiOncOglpu6ALlDqFZz5u4IPXInmOiSjHAbDa3_5FfFWurBUgU8K4xBXtz1pp4nKmQVs0c1u5BaTmm7PnHllSeuJyeHLJwspfe9xRApuXaSRpuAc_F9-GaNdIlwmZvxU0OfAtJUb5Ugx_VbeX8r99bO5DFYbiPCNnJTsjiWnmga8zFzOD87ikNZVOSQL28q7lOOa7ehb1bhNsdLviuyFJMfbr359u4I6cW2tgwDDg30oALKUpfu3FVsMRr5SBaWBmQiyx1G9Z2cuNPNtSMZF0hUPLtFbHNKgG7ARCAHLNtcbLGUKPzPcjGPAsaFw",
      "e": "AQAB"
    }
  ]
}

2つあります。先程のJWTのヘッダに書いてあった kidaeefb6c2fc062f48cfa510e209e0493fbff8e596 だったので1つ目の鍵であることが分かります。

公開鍵の生成

ne があるので m ≡ (signature)^e (mod n) で署名を復号して検証可能な状態なのですが、CLIだとうまくmodpowする方法が見つかりませんでした。プログラムを書くのであれば以下が参考になります。

OpenID ConnectのIDトークンの内容と検証 - sambaiz-net

何とかCLIだけでやりたいという謎の縛りプレーをしているので、openssl コマンドを使って公開鍵を作ってみます。 asn1parse-genconf フラグで作れそうです。ドキュメントにサンプルもあります。

/docs/manmaster/man3/ASN1_generate_nconf.html

まず nBase64エンコードされているので、デコードしてから16進数に変換します。

$ curl -s https://oauth2.sigstore.dev/auth/keys | jq -r '.keys[0].n' | base64 -d
ԦjQ\ base64: invalid input

パディングがないのでうまくデコードできませんでした。自分はGNUbase64使っているせいでエラーになっているのですが、macOSbase64はパディングがなくても勝手にデコードしてくれます。ただその場合も結局最後の1バイトが欠けてしまいました(少しハマった)。Base64の原理的には行けるはずなのですが、何で欠けるのかは不明です。今回は仕方ないので自分でパディングします。最後に == を足します。

$ echo -n "1KYfnZ9qUVwg-GVbWe_Idj-OomW5vflcwUvfLYLW6feWfo3oXK-9GYa0AMaMJZWTP888b35Gl9QbU8wIY2A-IAsz0S-0xtbdynL-KkAt6jGdDGqpVgdDj5FltVlh43Qt3m48tSBDfyCJATOicpNrDeEhPw9KOIvS2MK_1K67jfkFTc9oKdL_Viqeej-BP3tA2O07-BlA5R1xhDY0Ia2GiIJv3MyjIuqWtZxQl1L4yYyTKZXnZ9fN9TPrWhtW4vKEUzG2cnkbiqMmlolzq8GNMln0DPyPen6u45VlFQ1RP9NLwWPPpXa2VwdCM-Lk0FtnWB_fghHXZQhdbRvtkJL8Sw==" | /usr/bin/base64 -d | xxd -p -c0
d4a61f9d9f6a515c20f8655b59efc8763f8ea265b9bdf95cc14bdf2d82d6e9f7967e8de85cafbd1986b400c68c2595933fcf3c6f7e4697d41b53cc0863603e200b33d12fb4c6d6ddca72fe2a402dea319d0c6aa95607438f9165b55961e3742dde6e3cb520437f20890133a272936b0de1213f0f4a388bd2d8c2bfd4aebb8df9054dcf6829d2ff562a9e7a3f813f7b40d8ed3bf81940e51d7184363421ad8688826fdccca322ea96b59c509752f8c98c932995e767d7cdf533eb5a1b56e2f2845331b672791b8aa326968973abc18d3259f40cfc8f7a7eaee39565150d513fd34bc163cfa576b657074233e2e4d05b67581fdf8211d765085d6d1bed9092fc4b

デコードしたものをxxdを使って16進数にしています。

ちなみにBase64の原理については以下の記事で細かく書きました。

knqyf263.hatenablog.com

ということでopensslの設定ファイルを作ります。e も同様に取得しておいてください。多分 0x010001 です。

$ cat <<EOF > pub.conf 
# Start with a SEQUENCE
asn1=SEQUENCE:pubkeyinfo

# pubkeyinfo contains an algorithm identifier and the public key wrapped
# in a BIT STRING
[pubkeyinfo]
algorithm=SEQUENCE:rsa_alg
pubkey=BITWRAP,SEQUENCE:rsapubkey

# algorithm ID for RSA is just an OID and a NULL
[rsa_alg]
algorithm=OID:rsaEncryption
parameter=NULL

# Actual public key: modulus and exponent
[rsapubkey]
n=INTEGER:0xd4a61f9d9f6a515c20f8655b59efc8763f8ea265b9bdf95cc14bdf2d82d6e9f7967e8de85cafbd1986b400c68c2595933fcf3c6f7e4697d41b53cc0863603e200b33d12fb4c6d6ddca72fe2a402dea319d0c6aa95607438f9165b55961e3742dde6e3cb520437f20890133a272936b0de1213f0f4a388bd2d8c2bfd4aebb8df9054dcf6829d2ff562a9e7a3f813f7b40d8ed3bf81940e51d7184363421ad8688826fdccca322ea96b59c509752f8c98c932995e767d7cdf533eb5a1b56e2f2845331b672791b8aa326968973abc18d3259f40cfc8f7a7eaee39565150d513fd34bc163cfa576b657074233e2e4d05b67581fdf8211d765085d6d1bed9092fc4b

e=INTEGER:0x010001

EOF

あとは asn1parse で公開鍵を作ります。

$ openssl asn1parse -genconf pub.conf -out idpkey.der
    0:d=0  hl=4 l= 290 cons: SEQUENCE
    4:d=1  hl=2 l=  13 cons: SEQUENCE
    6:d=2  hl=2 l=   9 prim: OBJECT            :rsaEncryption
   17:d=2  hl=2 l=   0 prim: NULL
   19:d=1  hl=4 l= 271 prim: BIT STRING

無事に ne からIdPの公開鍵を作れました。idpkey.der に書き出しています。

検証

JWT内の署名をBase64デコードしたものを一旦ファイルに書き出します。

$ echo -n "peVaJbzFWmzG4ChHu0j1l_2uM9WLcnxmTdPxy9H_b1sFIcjks22Ij4GoTRF6pWGopVJyEE4Cx3Hl6woYDIix92KKXfCPZeimcaXO8y8B8uQUu5PyUTbt5z-hpipdY88Fgj3YGcLnl7Kh5j2aMXJt-f5ETOUdvVjwU4kstFCXs69yy60mPtBO5NdxF4m8Qi4jr2CjZ33hfdUm6jblf5Z3Jvk1KKHq3gXhrh6ZtEiD_fr007GQNKcLHw86uk8xDNsWIJNbmlpUKoutuMEzav0Qxsu8r8Q-x2kKQgNJ1oB7zS3eT8r4lvn0W8uCF0Kz9o-xRU-0KeqG3bFFzX1nyGDoIQ==" | /usr/bin/base64 -d > token.sig

JWTの署名を除いた部分も同様にファイルに書き出します。これが署名対象です。

echo -n "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlZWZiNmMyZmMwNjJmNDhjZmE1MTBlMjA5ZTA0OTNmYmZmOGU1OTYifQ.eyJpc3MiOiJodHRwczovL29hdXRoMi5zaWdzdG9yZS5kZXYvYXV0aCIsInN1YiI6IkNnY3lNalV6TmpreUVpWm9kSFJ3Y3pvbE1rWWxNa1puYVhSb2RXSXVZMjl0SlRKR2JHOW5hVzRsTWtadllYVjBhQSIsImF1ZCI6InNpZ3N0b3JlIiwiZXhwIjoxNjYzNjk3ODc2LCJpYXQiOjE2NjM2OTc4MTYsIm5vbmNlIjoibm9uY2UiLCJhdF9oYXNoIjoiNjB5RF9DN3NLamFVWWE0VkJ2M180ZyIsImNfaGFzaCI6ImJxSy1RNnc5RWFZOUNOUTYtRGs0U3ciLCJlbWFpbCI6ImtucXlmMjYzQGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJmZWRlcmF0ZWRfY2xhaW1zIjp7ImNvbm5lY3Rvcl9pZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9sb2dpbi9vYXV0aCIsInVzZXJfaWQiOiIyMjUzNjkyIn19" > token

あとはIdPの公開鍵(idpkey.der)を使ってJWTのヘッダとペイロード部分の署名を検証します。

$ openssl dgst -sha256 -verify idpkey.der -signature token.sig token
Verified OK

ということで検証できました。

ここまででOpenID Connectを使ってIDトークンを取得するというところは完了です。このあとにFulcioとのやり取りがありますが、それは次の記事で解説します。

参考

まとめ

Keyless Signingについての概要は当然把握していたのですが、実際に手動でやってみると色々と勉強になりますしハマりまくったおかげで忘れなくなったので、大変ですがやってみて良かったです。とりあえず今回はOIDC部分の理解を深めました。