訂閱以接收新文章的通知:

Linux 內核金鑰保留服務以及為何應將其用於下一個應用程式

2022/11/28

閱讀時間:24 分鐘
The Linux Kernel Key Retention Service and why you should use it in your next application

我們希望數位資料安全無虞。我們想要造訪網站、傳送銀行詳細資料、鍵入密碼、線上簽署文件、登入遠端電腦、對資料加密後再儲存至資料庫,以及確保沒有人能夠篡改。雖然密碼學能夠提供高度的資料安全性,但我們需要保護加密金鑰。

與此同時,我們並不能將寫下來的密鑰安全保管在某個地方,只是偶爾看一看。恰恰相反的是,這涉及到我們執行加密操作的每個請求。如果某個網站支援 TLS,則可使用私密金鑰建立每個連線。

遺憾的是,加密金鑰有時會洩露,若發生這種情況,可能後果很嚴重。許多洩露的始作俑者是軟體錯誤及安全漏洞。在這篇文章中,我們將了解 Linux 內核如何幫助保護加密金鑰免受一整類潛在安全漏洞的影響:記憶體存取違規事件。

記憶體存取違規事件

根據美國國家安全局的資料,Microsoft 和 Google 程式碼中約有 70% 的漏洞與記憶體安全問題存在相關性。不當存取記憶體的其中一個後果是洩漏網路安全資料(包括加密金鑰)。加密金鑰只是儲存於記憶體中的一些(主要為隨機性質)資料,因此,它們可能會像任何其他記憶體內資料一樣遭受記憶體洩露的影響。以下範例顯示了加密金鑰如何透過堆疊記憶體重複使用而意外洩露:

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」記錄器中的密鑰,而非預期日誌訊息!上面的程式碼有兩個問題:

  • 完成使用後,我們並未安全銷毀偽加密函數中的金鑰(例如,透過以零覆寫金鑰資料)。
  • 我們的錯誤記錄功能可以存取流程中的任何記憶體

雖然我們或許能夠藉由其他一些程式碼來輕鬆解決第一個問題,但第二個問題是軟體在作業系統內執行所帶來的固有後果。

作業系統為每個進程提供連續虛擬記憶體區塊,能讓內核在幾個同時執行的進程間共用有限的電腦資源。此方法稱為虛擬記憶體管理。在虛擬記憶體內,進程擁有專門的位址空間,並且無法存取其他進程的記憶體,但能存取其位址空間中的任何記憶體。在我們的範例中,我們對稱為堆疊的進程記憶體頗有興趣。

堆疊由堆疊框架組成。堆疊框架是為目前正在執行之函數動態分配的空間,包含函數的局部變數、參數及返回位址。編譯函數時,編譯器會計算需要分配多少記憶體,並請求相應大小的堆疊框架。待函數執行完畢,堆疊框架會標記為空閒,可供重複使用。堆疊框架是一個邏輯區塊,不提供任何邊界檢查,亦不會擦除,只會標記為空閒。此外,虛擬記憶體是一個連續的地址區塊。這兩個陳述式均為惡意軟體/錯誤程式碼提供了從虛擬記憶體中任意位置存取資料的可能性。

我們的程式 broken.c 的堆疊如下圖所示:

一開始是主函數的堆疊框架。接著,main() 函數調用 encrypt(),放置於緊接在 main()(程式碼堆疊向下成長)下方的堆疊中。在 encrypt() 內,編譯器為 key 變數請求 8 個位元組(7 個位元組的資料 + C 空字元)。當 encrypt() 完成執行時,log_completion() 會擷取相同的記憶體位址。在 log_completion() 內,編譯器為 msg 變數分配八個位元組。碰巧會將其放在我們之前儲存私密金鑰之相同位置的堆疊上。msg 的記憶體僅供分配,但未實施初始化,來自上一個函數的資料保持原樣。

此外,對於程式碼錯誤,程式設計語言提供安全記憶體漏洞的已知不安全函數。例如,對於 C,此類函數為 printf()strcpy()gets()。函數 printf() 不會檢查必須傳遞多少參數才能取代格式字串中的所有預留位置。函數參數放置在函數堆疊框架上方的堆疊上,printf() 根據預留位置的數量及類型擷取參數,輕鬆脫離其參數並從前一個函數的堆疊框架存取資料。

美國國家安全局建議我們使用記憶體安全語言,如 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 個(目前為止)漏洞之一的引述:

Python 2.7.14 易受堆積衝區溢位及堆積釋放後使用的影響。

Golang 有專門的溢位漏洞清單,並有一個不安全套件。套件名稱不言而喻,尋常規則及檢查在此套件中不起作用。

Heartbleed

2014 年,人們發現了 Heartbleed 漏洞。當時最常用的加密程式庫 OpenSSL 洩露了私密金鑰。我們經歷過這種情況。

緩解

因此,記憶體錯誤是生活中既有的事實,我們不能真正充分保護自身免受相應的影響。但是,鑒於加密金鑰比其他資料更具價值,我們能否最起碼更好地保護金鑰?

如前所述,記憶體地址空間通常與進程存在關聯。預設情況下,兩個不同進程並不會共用記憶體,所以自然而然彼此孤立。因此,其中一個進程的潛在記憶體錯誤並不會意外從另一個進程洩露加密金鑰。ssh-agent 的安全性建基於這個原則之上。始終有兩個相關的進程:用戶端/請求者及代理程式

代理程式絕不會透過其請求管道傳送私密金鑰。相反,需要私密金鑰的作業將由代理程式執行,結果將返回至請求者。如此一來,私密金鑰就不會暴露給使用代理程式的用戶端。

請求者通常為面向網路的進程及/或處理不受信任之輸入。因此,請求者更易遭受與記憶體相關之漏洞的影響,但在此方案中,永遠無法存取加密金鑰(因為金鑰位於單獨的進程位址空間中),因此絕不會洩露。

Cloudflare 在無密鑰 SSL 中採用了相同的原則。客戶私密金鑰儲存於隔離的環境中,受面向網際網路之連線的保護。

Linux 內核金鑰保留服務

用戶端/請求者及代理程式方法為機密或加密金鑰提供更好的保護,但卻存在一些缺點:

  • 我們需要開發及維護兩個不同的程式,而非一個程式
  • 我們還需要為兩個進程之間的通訊設計明確定義的介面
  • 我們需要實施兩個進程(Unix 通訊端、共用記憶體等)之間的通訊支援。
  • 我們可能需要在進程之間認證及支援 ACL,因為我們不希望系統上的任何請求者皆能使用我們儲存於代理程式內部的加密金鑰
  • 使用用戶端/請求者進程時,我們需要確保代理程式進程已啟動並執行

如果我們用 Linux 內核本身來取代代理程式進程,會怎麼樣?

  • 我們已在系統上予以執行(不然,我們的軟體將不起作用)
  • 此內核具備明確定義的通訊介面(系統調用)
  • 此內核可在內核物件上強制執行各種 ACL
  • 此內核已在單獨的位址空間執行!

幸運的是,Linux 內核金鑰保留服務能夠執行典型代理程式進程的所有功能,甚至可能還有更多功能!

起初,此內核專為 dm-crypt/ecryptfs 等內核服務而設計,但後來開放供使用者空間程式使用。它為我們帶來了一些優勢:

  • 這些金鑰儲存於進程位址空間之外
  • 明確定義的介面及通訊層透過系統調用來實現
  • 密鑰是內核物件,因此具有關聯的權限及 ACL
  • 金鑰生命週期可以隱式綁定至進程生命週期

Linux 內核金鑰保留服務使用兩種類型的實體運作:金鑰及密鑰環,其中,密鑰環屬於特殊的金鑰類型。如果我們將其與檔案及目錄類比,我們可以將金鑰說成是檔案,將密鑰環說成是目錄。此外,它們表示類似於檔案系統樹層次結構的金鑰層次結構:密鑰環引用金鑰及其他密鑰環,但僅金鑰能夠保存實際的加密材料,類似於保存實際資料的檔案。

金鑰具有多個類型。金鑰類型決定了可對金鑰執行哪些作業。例如,使用者及登入類型的金鑰能夠保存任意資料二進位大型物件,但登入金鑰絕不能讀回使用者空間,係由核心內服務獨佔使用。

為了使用內核而非代理程式進程,最有趣的金鑰類型為非對稱類型。此類型能在內核中保存私密金鑰,並為容許的應用程式提供使用此金鑰解密或簽署部分資料的能力。目前,僅支援 RSA 金鑰,但正努力新增 ECDSA 金鑰支援

雖然金鑰負責保護內核內的加密材料,但密鑰環決定金鑰存留期以及共用存取。在最簡單的形式中,當在銷毀特定的密鑰環時,亦僅會安全銷毀連結至此密鑰環的所有金鑰。我們能夠手動建立自訂密鑰環,但此服務最強大功能之一可能是「特殊密鑰環」。

這些密鑰環由內核隱式建立,其存留期已與不同內核物件(如進程或使用者)的存留期繫結。(目前有四種「隱式」密鑰環類別),但出於寫作本文章之初衷,我們對兩個應用最廣泛的密鑰環感興趣:進程密鑰環與使用者密鑰環。

使用者密鑰環存留期已繫結至現存的特定使用者,而此密鑰環在相同 UID 的所有進程之間共用。因此,例如,一個進程可將金鑰儲存至使用者密鑰環中,並可執行另一個進程,因為同一使用者可擷取/使用此金鑰。從系統中移除 UID 後,所關聯使用者密鑰環下的所有金鑰(以及其他密鑰環)均將被內核安全銷毀。

進程密鑰環會與某些進程繫結,可能具有三種語義不同的類型:進程、執行緒及工作階段。進程密鑰環會繫結並專供特定進程使用。因此,進程內的任何程式碼皆可在密鑰環中儲存/使用金鑰,但其他進程(即使具有相同的使用者 ID 或子進程)無法存取。當進程死亡時,密鑰環及關聯的金鑰均會安全銷毀。除了具備將密碼/金鑰儲存於隔離地址空間中的優勢,進程密鑰環還為我們提供保證,無論進程出於哪種原因終止,金鑰均會予以銷毀:即使我們的應用程式在沒有機會執行任何清理程式碼的情況下嚴重崩潰,我們的金鑰仍會被內核安全銷毀。

執行緒密鑰環類似於進程密鑰環,但它屬於私有性質,會與特定執行緒繫結。例如,我們能夠構建一個多執行緒網頁伺服器,能使用多個私密金鑰為 TLS 連線提供服務,並可確保一個執行緒中的連線/程式碼絕不能使用與另一個執行緒關聯的私密金鑰(例如,服務於不同的網域名稱)。

工作階段密鑰環使其金鑰可供當前進程及其所有子進程使用。當最頂層的進程終止且子進程可儲存/存取金鑰時,此密鑰環會被銷毀,而最頂層的進程仍然存在。當我們使用 keyctl 工具來存取 Linux 內核金鑰保留服務,而非使用內核系統調用介面時,密鑰環在殼層及互動式環境中最有用處。在殼層中,我們通常不能使用進程密鑰環,因為執行的每個指令均會建立一個新進程。因此,如果我們從指令行向進程密鑰環新增一個金鑰,此金鑰會立即銷毀,因為當指令完成執行時,「新增」進程即會終止。我們來使用 bpftrace 切實確認這一點。

在一個終端中,我們將追蹤負責刪除使用者金鑰的 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

因此,在上述範例中,金鑰「mykey」已新增至執行 keyctl 新增使用者 keyctl add user mykey hunter2 @p 之子殼層的進程密鑰環。但由於子殼層進程在執行指令的瞬間已終止,因此其進程密鑰環及新增的金鑰均會銷毀。

相反,工作階段密鑰環允許我們的互動式指令將金鑰新增至目前的殼層環境,並允許後續指令加以使用。當我們的主殼層進程終止時(很可能是當我們從系統中登出時),金鑰會被安全銷毀。

因此,透過選擇合適的密鑰環類型,我們能夠確保在不需要時安全銷毀金鑰,即使應用程式崩潰!雖然這段介紹非常簡短,但您可以播放我們的範例,如需了解來龍去脈,請閱覽我們的官方文件

以 Linux 內核金鑰保留服務取代 ssh 代理程式

我們已經詳細介紹如何使用 Linux 內核保留服務取代兩個隔離的進程。這正是將我們的話寫進程式碼的好時機。我們也談到了 ssh 代理程式,因此,將儲存於代理程式記憶體中的私密金鑰替換為內核內私密金鑰會是一個很好的做法。我們選擇了熱門的 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

我們需要從最新的 git 下載並修補 OpenSSH,因為上述補丁不適用於最新的版本(在撰寫本文時為 V_9_1_P1):

$ 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 內核金鑰保留服務的便捷包裝函式。此外,我們不得不停用 PKCS11 支援,因為程式碼具有與「libkeyutils」同名的函數,因此,存在命名衝突。可能會有更好的解決辦法,但這已經超出本文的闡述範圍。

現在我們已經擁有修補後的 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 代理程式中的任何金鑰。登入流程仍然不會為我們的私密金鑰請求任何密碼,金鑰本身存放於內核地址空間中,我們使用其序列簽名作業予以引用。

使用者或工作階段密鑰環?

在上面的範例中,我們將 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 指令碼。同樣,如果我們登入至第二個終端,我們將看不到此金鑰:

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

請注意,第二個終端中的工作階段密鑰環 _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 內核保留服務如何能夠幫助我們保護加密金鑰及密碼。

我們還為 OpenSSH 引入了一個有效的修補程式,以使用 Linux 內核的這個超酷功能,因此您可以輕鬆地自行嘗試。我們仍然還有一些未作介紹的 Linux 內核金鑰保留服務功能,可能會在另一篇部落格文章中講述。敬請期待!

我們保護整個企業網路,協助客戶有效地建置網際網路規模的應用程式,加速任何網站或網際網路應用程式抵禦 DDoS 攻擊,阻止駭客入侵,並且可以協助您實現 Zero Trust

從任何裝置造訪 1.1.1.1,即可開始使用我們的免費應用程式,讓您的網際網路更快速、更安全。

若要進一步瞭解我們協助打造更好的網際網路的使命,請從這裡開始。如果您正在尋找新的職業方向,請查看我們的職缺
Linux (TW)Kernel (TW)Deep Dive (TW)繁體中文

在 X 上進行關注

Ignat Korchagin|@ignatkn
Cloudflare|@cloudflare

相關貼文