knqyf263's blog

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

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