新規投稿のお知らせを受信されたい方は、サブスクリプションをご登録ください:

Linux Kernel Key Retention Serviceを次のアプリケーションにおすすめする理由

2022/11/28

14分で読了
The Linux Kernel Key Retention Service and why you should use it in your next application

当社が目指すのはデジタルデータの安全性の確保です。ウェブサイトの訪問、銀行の詳細の送信、パスワードの入力、オンラインでの文書署名、リモートコンピュータへのログイン、データベースへの保存前のデータ暗号化。それらを誰からも改ざんされない状態にしたいのです。暗号技術により高度なデータセキュリティは実現できますが、それでも暗号化キーの保護が必要です。

とはいえ、キーを安全な場所に書き込んでおいて、たまにアクセスするだけというわけにもいきません。むしろ逆で、暗号演算を行うすべてのリクエストに関わっているのですから。サイトがTLSをサポートしている場合、プライベートキーは各接続を確立するために使用されます。

残念ながら、暗号キーは漏洩するケースもあり、そうなれば大きな問題となります。漏洩の多くは原因はソフトウェアのバグやセキュリティの脆弱性なのです。この記事ではLinuxカーネルがどのようにして潜在的なセキュリティ脆弱性であるメモリアクセス違反から暗号キーを守っているのかをご説明します。

メモリアクセス違反

NSAによると、マイクロソフトとグーグルのコードの脆弱性における約70%は、メモリ安全性の問題に関連しているそうです。不正なメモリアクセスの結果として生じるものの1つが、(暗号キーを含む)セキュリティデータの漏洩です。暗号キーはメモリ上に保存された(主にランダムな)データであるため、他のインメモリデータと同様にメモリリークの対象となる可能性があります。以下の例では、スタックメモリの再利用により、暗号キーが誤って漏洩してしまう可能性があることを示しています。

broken.c

#include <stdio.h>
#include <stdint.h>

static void encrypt(void)
{
    uint8_t key[] = "hunter2";
    printf("encrypting with super secret key: %s\n", key);
}

static void log_completion(void)
{
    /* oh no, we forgot to init the msg */
    char msg[8];
    printf("not important, just fyi: %s\n", msg);
}

int main(void)
{
    encrypt();
    /* notify that we're done */
    log_completion();
    return 0;
}

プログラムをコンパイルして実行します。

$ gcc -o broken broken.c
$ ./broken 
encrypting with super secret key: hunter2
not important, just fyi: hunter2

おっと、"fyi "ロガーで意図したログメッセージではなく、シークレットキーを出力してしまいました。上記のコードには2つの問題があります。

  • 疑似暗号化機能でキーを安全に破棄しなかった(たとえば、キーデータをゼロで上書きするなど)
  • バグのあるロギング機能がプロセス内のいずれのメモリにもアクセスできてしまう

1つ目の問題はコードを追加することで簡単に解決できるでしょう。しかし、2つ目の問題はオペレーティングシステムの内部でソフトウェアがどのように動作するかという本質的な結果です。

各プロセスにはオペレーティングシステムから連続した仮想メモリのブロックが与えられます。これにより、カーネルは同時に実行されている複数のプロセス間で、限られたコンピュータ資源を共有することができます。この方法は仮想メモリ管理と呼ばれています。仮想メモリの内部でプロセスは独自のアドレス空間を持ち、他のプロセスのメモリにはアクセスできませんが、そのアドレス空間内にあるメモリにどれでもアクセス可能です。この例ではスタックと呼ばれるプロセスメモリの一部に注目しています。

スタックはスタックフレームで構成されています。スタックフレームは現在実行中の関数のために動的に割り当てられたスペースです。この関数には関数のローカル変数、引数、リターンアドレスが含まれます。関数をコンパイルする際、コンパイラはどれだけのメモリを割り当てる必要があるかを計算し、そのサイズのスタックフレームを要求します。関数の実行が終了するとスタックフレームは空き領域としてマークされ、再び使用することができるようになります。スタックフレームは論理ブロックであり、境界チェックを行わず、消去もせず、ただ空きとマークされるだけです。さらに、仮想メモリはアドレスの連続したブロックです。どちらもマルウェアやバグコードが、仮想メモリ内のどこからでもデータにアクセスできる可能性があることを意味しています。

当社のプログラムであるbroken.cのスタックは次のようになります。

冒頭にはmain関数のスタックフレームがあります。さらにmain()関数はencrypt()を呼び出します。これはmain()のすぐ下のスタックに置かれます(コードスタックは下に向かって大きくなっていきます)。 encrypt()の内部でコンパイラはkey変数に 8 バイトを要求します (7 バイトのデータ + C-null 文字)。  encrypt()の実行が終了すると、同じメモリアドレスがlog_completion()によって取得されます。log_completion()の中でコンパイラはmsg変数に8バイトを割り当てています。偶然にもこの変数は以前私たちのプライベートキーが格納されたのと同じ場所でスタックに格納されました。msgのメモリは割り当てられただけで初期化されておらず、前の関数のデータがそのまま残っています。

さらにコードバグに加え、プログラミング言語にはメ安全メモリ脆弱性で知られる、安全ではない関数が用意されています。たとえばC言語の場合、そのような関数は、printf()strcpy()gets()。 関数 printf()は、フォーマット文字列内のすべてのプレースホルダーを置き換えるために渡す必要がある引数の数をチェックしません。関数の引数は関数のスタックフレームの上のスタックに置かれます。 printf()はプレースホルダの数と種類に従って引数を取得し、簡単にその引数を外れて、前の関数のスタックフレームからデータにアクセスします。

NSAはPython、Go、Rustのような安全メモリ言語を使うようにと助言しています。でも、それで完全に安全だと言えるのでしょうか?

Pythonコンパイラは多くの場合、漏れなく境界をチェックしてエラーで通知します。

>>> print("x: {}, y: {}, {}".format(1, 2))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: Replacement index 2 out of range for positional args tuple

しかし、これは(現時点で)36件ある脆弱性の1つからの引用です。

Python 2.7.14 にはHeap-Buffer-OverflowおよびHeap-Use-After-Freeの脆弱性が存在します。

Golangは オーバーフローの脆弱性 安全でないパッケージの独自のリストがあります。その名が表している通り、通常のルールとチェックはこのパッケージの中では機能しません。

Heartbleed

2014年にHeartbleedバグが発見されました。(当時)最も使用されていた暗号化ライブラリOpenSSLからプライベートキーが漏洩したのです。当社も例外ではありませんでした

軽減

そう、メモリバグは日常茶飯事であり、完全に安全ということはあり得ないのです。しかし、暗号キーは他のデータよりもはるかに重要性が高いことを考えると、せめてキーの保護だけでも改善できないものでしょうか。

すでに述べたように、通常、メモリアドレス空間はプロセスに関連付けられます。そして、2つの異なるプロセスはデフォルトではメモリを共有しないので、当然ながら互いに隔離されています。したがって、あるプロセスで潜在的なメモリバグが発生しても、他のプロセスから暗号キーが誤って漏洩することはありません。ssh-agent のセキュリティはこの原理に基づいています。クライアントまたはリクエスタとエージェントの2つのプロセスが常に関与しています。

エージェントはリクエストチャンネルでプライベートキーを送信することはありません。その代わり、プライベートキーを必要とする操作はエージェントが実行し、結果はリクエスタに返されます。この方法では、プライベートキーがエージェントを使用するクライアントに公開されません。

リクエスタは通常、ネットワークに接続するプロセスまたは信頼できない入力を処理するプロセスですしたがって、リクエスタはメモリ関連の脆弱性の影響をかなり受けやすいのですが、このスキームでは暗号化キーにアクセスできないので(キーは別のプロセスアドレス空間に存在するため)、漏洩することはありません。

Cloudflareでは同じ原理をKeyless SSLに採用しています。お客様のプライベートキーは隔離された環境に保管され、インターネット接続から保護されます。

Linux Kernel Key Retention Service

クライアントまたはリクエスタと、エージェントのアプローチは、プライベートキーまたは暗号化キーの保護を強化しますが、いくつかの欠点があります。

  • 1つではなく2つのプログラムを開発し、維持していく必要がある
  • また、2つのプロセス間の通信のために、明確に定義されたインターフェイスを設計する必要がある
  • 2つのプロセス間の通信サポートを実装する必要がある(Unixソケットや共有メモリなど)
  • プロセス間の認証とACLへのサポートをしなければならない可能性がある。システムのどのリクエスタも、エージェント内部に保存されている暗号キーを使用できないようにしたいため
  • クライアントまたはリクエスタのプロセスと連携する場合、エージェントプロセスが稼働していることを確認する必要がある

それではエージェントプロセスをLinuxカーネルそのものに置き換えてみたたらどうなるでしょう。

  • すでに自社システムで動作している(そうでなければソフトウェアは動作していない)
  • 通信のための明確に定義されたインターフェイス(システムコール)がある
  • カーネルオブジェクトにさまざまなACLを適用することができる
  • しかも別のアドレス空間で動作している

ありがたいことにLinux Kernel Key Retention Serviceは、典型的なエージェントプロセスのすべての機能、おそらくそれ以上の機能を実行することができます。

もともとはdm-crypt/ecryptfsのようなカーネルサービス用に設計されたものですが、後にユーザー空間プログラムでも使用できるように開放されました。それは当社にもメリットがあります。

  • キーがプロセスのアドレス空間の外に格納されている
  • well-defined-interfaceと通信レイヤーがsyscallで実装されている
  • キーはカーネルオブジェクトであるため、アクセス許可とACLが関連付けられている
  • キーのライフサイクルはプロセスのライフサイクルに暗黙的にバインドできます

Linux Kernel Key Retention Service は、キーとキーリングの2類のエンティティで動作します。キーリングは特別なタイプのキーです。ファイルやディレクトリに例えるなら、キーがファイルで、キーホルダーはディレクトリと言えるでしょう。さらに、ファイルシステムのツリー階層に似たキー階層を表しています。キーリングはキーや他のキーリングを参照しますが、実際のデータを保持するファイルと同様に、実際の暗号化マテリアルを保持できるのはキーだけです。

キーにはいくつかの種類があります。キーの種類によって、キーに対して実行できる操作が決まります。たとえば、ユーザー型とログオン型のキーは任意のブロブデータを保持できますが、ログオンキーはユーザー空間に読み込まれることはなく、カーネル内サービスによって排他的に使用されます。

エージェントプロセスの代わりにカーネルを使用する目的において、最も興味深いキーの種類なのは非対称タイプです。これはカーネル内にプライベートキーを保持でき、許可されたアプリケーションがキーを使用して一部のデータを復号化または署名できるようにします。現在はRSAキーのみをサポートしていますが、 ECDSAキーのサポートを追加する作業が進行中です。

キーはカーネル内の暗号化マテリアルを保護する役割を果たしますが、キーリングはキーの有効期間と共有アクセスを決定します。最も単純な形式では、特定のキーリングが破棄されると、そのキーリングにのみリンクされているすべてのキーも安全に破棄されます。カスタムキーホルダーを手動で作成することもできますが、このサービスの最も強力な機能の1つは、「スペシャルキーホルダー」でしょう。

これらのキーリングはカーネルによって暗黙的に作成され、その寿命はプロセスやユーザーのような別のカーネルオブジェクトの寿命に束縛されています。(現在「暗黙の」 キーリングの4つのカテゴリがありますが、本記事の目的では、最も広く使われている2つのカテゴリ、プロセスキーリングとユーザーキーリングに注目します。

ユーザーキーリングの有効期間は特定のユーザーの存在にバインドされており、このキーリングは同じUIDのすべてのプロセス間で共有されます。したがって、あるプロセスがキーをユーザーキーリングに保存し、同じユーザーとして実行されている別のプロセスがキーを取得または使用などが可能です。UIDがシステムから削除されると、関連するユーザーキーリングの下にあるすべてのキー(およびその他のキーリング) がカーネルによって安全に破棄されます。

プロセスキーリングはいくつかのプロセスにバインドされており、セマンティクスが異なる3つのタイプ(プロセス、スレッド、セッション) である可能性があります。プロセスのキーリングは特定のプロセスにバインドされ、非公開です。したがって、したがって、プロセス内のコードはキーリングにキーを保存または使用できますが、他のプロセス (同じユーザーIDまたは子プロセスであっても)はアクセスできません。そして、プロセスが終了するとキーリングと関連するキーは安全に破棄されます。プライベートキーを分離されたアドレス空間に保存するという利点に加えて、プロセスキーリングにより、プロセス終了の理由に関係なく、キーが破棄されることが保証されます。クリーンアップコードを実行する機会が与えられずにアプリケーションが激しくクラッシュした場合でも、キーはカーネルによって安全に破棄されます。

スレッドキーリングはプロセスキーリングと似ていますが、プライベートで特定のスレッドにバインドされています。たとえば、複数のプライベートキーを使用してTLS接続を提供できるマルチスレッドWebサーバーを構築できます。また、あるスレッドの接続またはコードが別のスレッドに関連付けられているプライベートキーを使用できないことを確認できます (たとえば、別のドメイン名を提供しています)。

セッションキーリングは、そのキーを現在のプロセスとそのすべての子プロセスで使用できるようにします。最上位のプロセスが存在している間、最上位のプロセスが終了し、子プロセスがキーを保存またはアクセスできるようになると、破棄されます。シェルや対話型の環境では、カーネルのシステムコールインターフェースではなく、 keyctl toolを使ってLinux Kernel Key Retention Serviceにアクセスするときに、主に役に立ちます。シェルでは、実行されるすべてのコマンドが新しいプロセスを作成するため、通常プロセスキーリングは使用できません。したがって、コマンドラインからプロセスキーリングにキーを追加すると、コマンドの実行が終了すると「追加」プロセスが終了するため、そのキーはすぐに破棄されます。実際にbpftrace で確認してみましょう。

1つのターミナルで、コマンドの実行が終了すると「追加」プロセスが終了するため、ユーザーキーの削除を担当するuser_destroy関数をトレースします。

$ sudo bpftrace -e 'kprobe:user_destroy { printf("destroying key %d\n", ((struct key *)arg0)->serial) }'
Att

そして、別の端末でプロセスキーリングにキーを追加してみましょう。

$ keyctl add user mykey hunter2 @p
742524855

すぐに目にする最初のターミナルに戻ります。

…
Attaching 1 probe...
destroying key 742524855

そして、そのキーにアクセスしようとすると、キーが使用不可能であることが確認できます。

$ keyctl print 742524855
keyctl_read_alloc: Required key not available

つまり、上記の例では、keyctl add user mykey hunter2 @pを実行しているサブシェルのプロセスキーリングにキー「mykey」が追加されたことになります。しかし、サブシェルのプロセスはコマンドを実行した瞬間に終了したため、そのプロセスのキーリングと追加されたキーの両方が破棄されました。

その代わり、セッションキーリングによって、対話的コマンドは現在のシェル環境にキーを追加し、後続のコマンドはそれを消費することができます。キーはメインシェルプロセスが終了したとき (システムからログアウトしたときかもしれません)に安全に破棄されます。

そのため、適切なキーホルダータイプを選択することで、不要になったキーを安全に破棄することができます。仮にアプリがクラッシュしたときでもです。非常に簡単な紹介になりますが、全体のコンテキストについての例をいくつかお試しいただけます。公式ドキュメントをご利用ください。

ssh-agentをLinux Kernel Key Retention Serviceに置き換える

ここまで2つの分離されたプロセスをLinux Kernel Retention Service に置き換える方法について、詳しく説明してきました。それでは当社のワードをコードに入れていきましょう。ssh-agentについても説明したので、エージェントのメモリに保存されているプライベートキーをカーネル内のものに置き換える良い練習になります。私たちは最も人気のあるSSH実装OpenSSH を対象にしました。

カーネルからキーを取得する機能を追加するため、若干のコード変更が必要です。

openssh.patch

diff --git a/ssh-rsa.c b/ssh-rsa.c
index 6516ddc1..797739bb 100644
--- a/ssh-rsa.c
+++ b/ssh-rsa.c
@@ -26,6 +26,7 @@
 
 #include <stdarg.h>
 #include <string.h>
+#include <stdbool.h>
 
 #include "sshbuf.h"
 #include "compat.h"
@@ -63,6 +64,7 @@ ssh_rsa_cleanup(struct sshkey *k)
 {
 	RSA_free(k->rsa);
 	k->rsa = NULL;
+	k->serial = 0;
 }
 
 static int
@@ -220,9 +222,14 @@ ssh_rsa_deserialize_private(const char *ktype, struct sshbuf *b,
 	int r;
 	BIGNUM *rsa_n = NULL, *rsa_e = NULL, *rsa_d = NULL;
 	BIGNUM *rsa_iqmp = NULL, *rsa_p = NULL, *rsa_q = NULL;
+	bool is_keyring = (strncmp(ktype, "ssh-rsa-keyring", strlen("ssh-rsa-keyring")) == 0);
 
+	if (is_keyring) {
+		if ((r = ssh_rsa_deserialize_public(ktype, b, key)) != 0)
+			goto out;
+	}
 	/* Note: can't reuse ssh_rsa_deserialize_public: e, n vs. n, e */
-	if (!sshkey_is_cert(key)) {
+	else if (!sshkey_is_cert(key)) {
 		if ((r = sshbuf_get_bignum2(b, &rsa_n)) != 0 ||
 		    (r = sshbuf_get_bignum2(b, &rsa_e)) != 0)
 			goto out;
@@ -232,28 +239,46 @@ ssh_rsa_deserialize_private(const char *ktype, struct sshbuf *b,
 		}
 		rsa_n = rsa_e = NULL; /* transferred */
 	}
-	if ((r = sshbuf_get_bignum2(b, &rsa_d)) != 0 ||
-	    (r = sshbuf_get_bignum2(b, &rsa_iqmp)) != 0 ||
-	    (r = sshbuf_get_bignum2(b, &rsa_p)) != 0 ||
-	    (r = sshbuf_get_bignum2(b, &rsa_q)) != 0)
-		goto out;
-	if (!RSA_set0_key(key->rsa, NULL, NULL, rsa_d)) {
-		r = SSH_ERR_LIBCRYPTO_ERROR;
-		goto out;
-	}
-	rsa_d = NULL; /* transferred */
-	if (!RSA_set0_factors(key->rsa, rsa_p, rsa_q)) {
-		r = SSH_ERR_LIBCRYPTO_ERROR;
-		goto out;
-	}
-	rsa_p = rsa_q = NULL; /* transferred */
 	if ((r = sshkey_check_rsa_length(key, 0)) != 0)
 		goto out;
-	if ((r = ssh_rsa_complete_crt_parameters(key, rsa_iqmp)) != 0)
-		goto out;
-	if (RSA_blinding_on(key->rsa, NULL) != 1) {
-		r = SSH_ERR_LIBCRYPTO_ERROR;
-		goto out;
+
+	if (is_keyring) {
+		char *name;
+		size_t len;
+
+		if ((r = sshbuf_get_cstring(b, &name, &len)) != 0)
+			goto out;
+
+		key->serial = request_key("asymmetric", name, NULL, KEY_SPEC_PROCESS_KEYRING);
+		free(name);
+
+		if (key->serial == -1) {
+			key->serial = 0;
+			r = SSH_ERR_KEY_NOT_FOUND;
+			goto out;
+		}
+	} else {
+		if ((r = sshbuf_get_bignum2(b, &rsa_d)) != 0 ||
+			(r = sshbuf_get_bignum2(b, &rsa_iqmp)) != 0 ||
+			(r = sshbuf_get_bignum2(b, &rsa_p)) != 0 ||
+			(r = sshbuf_get_bignum2(b, &rsa_q)) != 0)
+			goto out;
+		if (!RSA_set0_key(key->rsa, NULL, NULL, rsa_d)) {
+			r = SSH_ERR_LIBCRYPTO_ERROR;
+			goto out;
+		}
+		rsa_d = NULL; /* transferred */
+		if (!RSA_set0_factors(key->rsa, rsa_p, rsa_q)) {
+			r = SSH_ERR_LIBCRYPTO_ERROR;
+			goto out;
+		}
+		rsa_p = rsa_q = NULL; /* transferred */
+		if ((r = ssh_rsa_complete_crt_parameters(key, rsa_iqmp)) != 0)
+			goto out;
+		if (RSA_blinding_on(key->rsa, NULL) != 1) {
+			r = SSH_ERR_LIBCRYPTO_ERROR;
+			goto out;
+		}
 	}
 	/* success */
 	r = 0;
@@ -333,6 +358,21 @@ rsa_hash_alg_nid(int type)
 	}
 }
 
+static const char *
+rsa_hash_alg_keyctl_info(int type)
+{
+	switch (type) {
+	case SSH_DIGEST_SHA1:
+		return "enc=pkcs1 hash=sha1";
+	case SSH_DIGEST_SHA256:
+		return "enc=pkcs1 hash=sha256";
+	case SSH_DIGEST_SHA512:
+		return "enc=pkcs1 hash=sha512";
+	default:
+		return NULL;
+	}
+}
+
 int
 ssh_rsa_complete_crt_parameters(struct sshkey *key, const BIGNUM *iqmp)
 {
@@ -433,7 +473,14 @@ ssh_rsa_sign(struct sshkey *key,
 		goto out;
 	}
 
-	if (RSA_sign(nid, digest, hlen, sig, &len, key->rsa) != 1) {
+	if (key->serial > 0) {
+		len = keyctl_pkey_sign(key->serial, rsa_hash_alg_keyctl_info(hash_alg), digest, hlen, sig, slen);
+		if ((long)len == -1) {
+			ret = SSH_ERR_LIBCRYPTO_ERROR;
+			goto out;
+		}
+	}
+	else if (RSA_sign(nid, digest, hlen, sig, &len, key->rsa) != 1) {
 		ret = SSH_ERR_LIBCRYPTO_ERROR;
 		goto out;
 	}
@@ -705,6 +752,18 @@ const struct sshkey_impl sshkey_rsa_impl = {
 	/* .funcs = */		&sshkey_rsa_funcs,
 };
 
+const struct sshkey_impl sshkey_rsa_keyring_impl = {
+	/* .name = */		"ssh-rsa-keyring",
+	/* .shortname = */	"RSA",
+	/* .sigalg = */		NULL,
+	/* .type = */		KEY_RSA,
+	/* .nid = */		0,
+	/* .cert = */		0,
+	/* .sigonly = */	0,
+	/* .keybits = */	0,
+	/* .funcs = */		&sshkey_rsa_funcs,
+};
+
 const struct sshkey_impl sshkey_rsa_cert_impl = {
 	/* .name = */		"[email protected]",
 	/* .shortname = */	"RSA-CERT",
diff --git a/sshkey.c b/sshkey.c
index 43712253..3524ad37 100644
--- a/sshkey.c
+++ b/sshkey.c
@@ -115,6 +115,7 @@ extern const struct sshkey_impl sshkey_ecdsa_nistp521_cert_impl;
 #  endif /* OPENSSL_HAS_NISTP521 */
 # endif /* OPENSSL_HAS_ECC */
 extern const struct sshkey_impl sshkey_rsa_impl;
+extern const struct sshkey_impl sshkey_rsa_keyring_impl;
 extern const struct sshkey_impl sshkey_rsa_cert_impl;
 extern const struct sshkey_impl sshkey_rsa_sha256_impl;
 extern const struct sshkey_impl sshkey_rsa_sha256_cert_impl;
@@ -154,6 +155,7 @@ const struct sshkey_impl * const keyimpls[] = {
 	&sshkey_dss_impl,
 	&sshkey_dsa_cert_impl,
 	&sshkey_rsa_impl,
+	&sshkey_rsa_keyring_impl,
 	&sshkey_rsa_cert_impl,
 	&sshkey_rsa_sha256_impl,
 	&sshkey_rsa_sha256_cert_impl,
diff --git a/sshkey.h b/sshkey.h
index 771c4bce..a7ae45f6 100644
--- a/sshkey.h
+++ b/sshkey.h
@@ -29,6 +29,7 @@
 #include <sys/types.h>
 
 #ifdef WITH_OPENSSL
+#include <keyutils.h>
 #include <openssl/rsa.h>
 #include <openssl/dsa.h>
 # ifdef OPENSSL_HAS_ECC
@@ -153,6 +154,7 @@ struct sshkey {
 	size_t	shielded_len;
 	u_char	*shield_prekey;
 	size_t	shield_prekey_len;
+	key_serial_t serial;
 };
 
 #define	ED25519_SK_SZ	crypto_sign_ed25519_SECRETKEYBYTES

OpenSSHの最新リリース (現時点でV_9_1_P1) では上記のパッチが動作しないため、最新のgitでダウンロードしてパッチを適用する必要があります。

$ git clone https://github.com/openssh/openssh-portable.git
…
$ cd openssl-portable
$ $ patch -p1 < ../openssh.patch
patching file ssh-rsa.c
patching file sshkey.c
patching file sshkey.h

パッチを適用したOpenSSHをコンパイルしてビルド

$ autoreconf
$ ./configure --with-libs=-lkeyutils --disable-pkcs11
…
$ make
…

libkeyutils Linux Kernel Key Retention Serviceにアクセスするための便利なラッパーを提供するlibkeyutilsと追加でリンクするようにビルドシステムに指示しています。さらに、このコードには `libkeyutils` と同じ名前の関数があり、名前の衝突があるため、PKCS11 のサポートを無効にする必要がありました。この点については、もっと良い修正があるかもしれませんが、本記事のテーマ外の内容ですね。

さて、パッチを適用したOpenSSHを手に入れたので、テストしてみましょう。まず、システムへのアクセスに使用する新しいSSH RSAキーを生成する必要があります。LinuxカーネルはPKCS8形式のプライベートキーしかサポートしていないので、(OpenSSHのデフォルト形式ではなく)最初からこの形式を使うことにします。

$ ./ssh-keygen -b 4096 -m PKCS8
Generating public/private rsa key pair.
…

通常は `ssh-add`を使用して、このキーをsshエージェントに追加します。この場合、代替スクリプトを使用して、現在のセッションのキーリングにキーを追加する必要があります。

ssh-add-keyring.sh

#/bin/bash -e

in=$1
key_desc=$2
keyring=$3

in_pub=$in.pub
key=$(mktemp)
out="${in}_keyring"

function finish {
    rm -rf $key
}
trap finish EXIT

# https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key
# null-terminanted openssh-key-v1
printf 'openssh-key-v1\0' > $key
# cipher: none
echo '00000004' | xxd -r -p >> $key
echo -n 'none' >> $key
# kdf: none
echo '00000004' | xxd -r -p >> $key
echo -n 'none' >> $key
# no kdf options
echo '00000000' | xxd -r -p >> $key
# one key in the blob
echo '00000001' | xxd -r -p >> $key

# grab the hex public key without the (00000007 || ssh-rsa) preamble
pub_key=$(awk '{ print $2 }' $in_pub | base64 -d | xxd -s 11 -p | tr -d '\n')
# size of the following public key with the (0000000f || ssh-rsa-keyring) preamble
printf '%08x' $(( ${#pub_key} / 2 + 19 )) | xxd -r -p >> $key
# preamble for the public key
# ssh-rsa-keyring in prepended with length of the string
echo '0000000f' | xxd -r -p >> $key
echo -n 'ssh-rsa-keyring' >> $key
# the public key itself
echo $pub_key | xxd -r -p >> $key

# the private key is just a key description in the Linux keyring
# ssh will use it to actually find the corresponding key serial
# grab the comment from the public key
comment=$(awk '{ print $3 }' $in_pub)
# so the total size of the private key is
# two times the same 4 byte int +
# (0000000f || ssh-rsa-keyring) preamble +
# a copy of the public key (without preamble) +
# (size || key_desc) +
# (size || comment )
priv_sz=$(( 8 + 19 + ${#pub_key} / 2 + 4 + ${#key_desc} + 4 + ${#comment} ))
# we need to pad the size to 8 bytes
pad=$(( 8 - $(( priv_sz % 8 )) ))
# so, total private key size
printf '%08x' $(( $priv_sz + $pad )) | xxd -r -p >> $key
# repeated 4-byte int
echo '0102030401020304' | xxd -r -p >> $key
# preamble for the private key
echo '0000000f' | xxd -r -p >> $key
echo -n 'ssh-rsa-keyring' >> $key
# public key
echo $pub_key | xxd -r -p >> $key
# private key description in the keyring
printf '%08x' ${#key_desc} | xxd -r -p >> $key
echo -n $key_desc >> $key
# comment
printf '%08x' ${#comment} | xxd -r -p >> $key
echo -n $comment >> $key
# padding
for (( i = 1; i <= $pad; i++ )); do
    echo 0$i | xxd -r -p >> $key
done

echo '-----BEGIN OPENSSH PRIVATE KEY-----' > $out
base64 $key >> $out
echo '-----END OPENSSH PRIVATE KEY-----' >> $out
chmod 600 $out

# load the PKCS8 private key into the designated keyring
openssl pkcs8 -in $in -topk8 -outform DER -nocrypt | keyctl padd asymmetric $key_desc $keyring

カーネルのコンパイル方法によっては、非対称プライベートキーをサポートするために、いくつかのカーネルモジュールをロードする必要があるかもしれません。

$ sudo modprobe pkcs8_key_parser
$ ./ssh-add-keyring.sh ~/.ssh/id_rsa myssh @s
Enter pass phrase for ~/.ssh/id_rsa:
723263309

最後に、sshプライベートキーが"myssh"という名前で現在のセッションキーリングに追加されます。さらにssh-add-keyring.sh~/.ssh/id_rsa_keyringに疑似プライベートキーファイルを作成し、メイン sshプロセスに渡す必要があるのですが、これは擬似的なプライベートキーで、機密性の高い暗号材料を持っていないからです。そのかわり、ネイティブ OpenSSH形式の「myssh」識別子のみが含まれます。もし複数のSSHキーを使うなら、メインのsshプロセスに、どのカーネル内のキーの名前をシステムから要求されるべきかを何らかの方法で伝えなければなりません。

テストを始める前に、(ローカルで動作している)SSH サーバーが新しく生成されたキーを有効な認証として受け入れることを確認しましょう。

$ cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys

これでシステムへのSSH接続を試すことができます。

$ SSH_AUTH_SOCK="" ./ssh -i ~/.ssh/id_rsa_keyring localhost
The authenticity of host 'localhost (::1)' can't be established.
ED25519 key fingerprint is SHA256:3zk7Z3i9qZZrSdHvBp2aUYtxHACmZNeLLEqsXltynAY.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'localhost' (ED25519) to the list of known hosts.
Linux dev 5.15.79-cloudflare-2022.11.6 #1 SMP Mon Sep 27 00:00:00 UTC 2010 x86_64
…

うまくいきましたね。`SSH_AUTH_SOCK`環境変数をリセットして、システム上で動作しているssh-agentのキーを使用しないようにしています。しかし、ログインフローはプライベートキーのパスワードを要求しません。キー自体はカーネルアドレス空間に存在し、署名操作のためにシリアルを使用して参照します。

ユーザーキーホルダーかセッションキーホルダーか

上記の例ではSSHのプライベートキーをセッションキーリングにセットアップしています。存在を確認することができます。

$ keyctl show
Session Keyring
 577779279 --alswrv   1000  1000  keyring: _ses
 846694921 --alswrv   1000 65534   \_ keyring: _uid.1000
 723263309 --als--v   1000  1000   \_ asymmetric: myssh

ユーザーキーリングも使用した可能性があります。その違いは何でしょう。現在、「myssh」キーの有効期間は現在のログインセッションに制限されています。つまり、ログアウトして再度ログインすると、キーが失われ、ssh-add-keyring.shスクリプトを再度実行する必要があります。同様に2 番目の端末にログインすると、このキーは表示されません。

$ keyctl show
Session Keyring
 333158329 --alswrv   1000  1000  keyring: _ses
 846694921 --alswrv   1000 65534   \_ keyring: _uid.1000

2番目の端末のセッションキーリング_sesのシリアルナンバーが違っていますね。新しいキーリングが作成され、「myssh」キーと以前のセッションキーリングは存在しなくなりました

$ SSH_AUTH_SOCK="" ./ssh -i ~/.ssh/id_rsa_keyring localhost
Load key "/home/ignat/.ssh/id_rsa_keyring": key not found
…

ssh-add-keyring.shにプライベートキーをユーザーのキーリングに読み込むように指示すると(コマンドラインパラメータの@s@uに置き換える)、両方のログインセッションから利用可能でアクセスできるようになります。この場合、ログアウトと再ログイン時に、同じキーが提示されます。ただし、これにはセキュリティ上の欠点があります。ユーザー ID として実行されているプロセスはすべて、キーにアクセスして使用できます。

まとめ

今回はとても貴重な暗号化キーを含むデータが漏洩する最も一般的なケースを見てきました。Cloudflareをはじめ、世界中の多くのユーザーに影響を与えた、いくつかの実例についてお話させていただきました。最後はLinux Kernel Retention Serviceが暗号キーやプライベートキーの保護にどのように役立つかをご紹介しました。

また、Linux カーネルのこの優れた機能を使用するための OpenSSH用の作業パッチも導入したので、簡単にお試しいただけます。まだご説明していないLinux Kernel Key Retention Serviceの機能はたくさんありますが、別の記事でお話できたらと思います。ご期待ください。

Cloudflareは企業ネットワーク全体を保護し、お客様がインターネット規模のアプリケーションを効率的に構築し、あらゆるWebサイトやインターネットアプリケーションを高速化し、DDoS攻撃を退けハッカーの侵入を防ぎゼロトラスト導入を推進できるようお手伝いしています。

ご使用のデバイスから1.1.1.1 にアクセスし、インターネットを高速化し安全性を高めるCloudflareの無料アプリをご利用ください。

より良いインターネットの構築支援という当社の使命について、詳しくはこちらをご覧ください。新たなキャリアの方向性を模索中の方は、当社の求人情報をご覧ください。
Linux (JP)Kernel (JP)Deep Dive (JP)日本語

Xでフォロー

Ignat Korchagin|@ignatkn
Cloudflare|@cloudflare