在 Emacs 中使用 org.freedesktop.Secrets 存取 API Key

大部分 AI 相关的 Emacs 包都推荐用 auth-source 来存取用到 API Key。不过除了 auth-source 外,Emacs 还内置了一个 secrets 包,可以和 org.freedesktop.Secrets 通信来管理 secret。

在 Linux 桌面环境上用得比较多的 Secrets API 实现应该是 KDE Wallet 和 GNOME Keyring,但我一直都没搞懂 KDE Wallet 是怎么工作的,所以我用的是 KeePassXC 的实现:

落实在 Emacs 中就是用 secrets-create-item 创建新的 API key 和用 secrets-get-secret 获取已经保存的 API key。

这里是一个给 gptel 接 API key 的例子:

(setq gptel-backend
			(gptel-make-openai "OpenRouter"
				:host "openrouter.ai"
				:endpoint "/api/v1/chat/completions"
				:stream t
				:key (lambda ()
							 (secrets-get-secret "Main" "emacs-openrouter-api-key"))
				:models '(google/gemini-2.5-pro-preview)))

至于具体怎么使用 KeePassXC 作为 Emacs 的钥匙圈,有兴趣的话可以看一下我博客上一篇文章

7 个赞

把 key 放到环境变量,Linux 一个 grep /proc/*/environ 就全出来了。

你应该是指 Aidermacs 设参数传 API key 的方式?Aider 似乎也可以用命令行参数的形式传进去,但感觉都是权宜之策,我打算给 Aider 提个 PR 什么的,但等做出来合并之前好像也只能这样凑合 :melting_face:

命令行参数更不靠谱,ps一下就全出来了

刚看了一下 Aider 的代码,似乎从命令行进去也只是 parse 完再设环境变量,估计是上游的 litellm 要用到这个环境变量来设 API key:

https://github.com/Aider-AI/aider/blob/836aaece4f251e522dc4b32629cdad1a3ee97eb1/aider/main.py#L600

感觉现在的方法都不怎么靠谱 :sweat_smile:

刚看了下 secrets.el 的代码,发现 secrets.el 只支持 plain 算法,不支持 dh-ietf1024-sha256-aes128-cbc-pkcs7,这样的话 D-Bus 传输的密码也是没有加密的。

Hack 了一下,加了 dh-ietf1024-sha256-aes128-cbc-pkcs7 的支持。

From f5cbc9abd5acdc0637480817cb2d037e816ed273 Mon Sep 17 00:00:00 2001
From: Zhengyi Fu <[email protected]>
Date: Sat, 14 Jun 2025 00:17:23 +0800
Subject: [PATCH] Add support for dh-ietf1024-sha256-aes128-cbc-pkcs7 to
 secrets.el

* lisp/net/secrets.el (secrets-session-algorithm)
(secrets-session-aes-key, secrets-dh-prime): New variables.
(secrets-integer-to-unibyte-string)
(secret-unibyte-string-to-integer, secrets-generate-key-pair)
(secrets-compute-aes-key, secrets-unibyte-string-to-byte-array):
New functions.
(secrets-close-session): Reset `secrets-session-aes-key'.
(secrets-open-session): Use `dh-ietf1024-sha256-aes128-cbc-pkcs7'
algorithm first.  If failed, then fallback to `plain'.
(secrets-pkcs7-pad, secrets-pkcs7-unpad, secrets-encrypt-secret)
(secrets-decrypt-secret): New functions.
(secrets-create-item, secrets-get-secret): Use
`secrets-encrypt-secret' and `secrets-decrypt-secret'
---
 lisp/net/secrets.el | 149 ++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 136 insertions(+), 13 deletions(-)

diff --git a/lisp/net/secrets.el b/lisp/net/secrets.el
index 049def1dc8d..8170d089639 100644
--- a/lisp/net/secrets.el
+++ b/lisp/net/secrets.el
@@ -329,13 +329,73 @@ secrets-session-path
   "The D-Bus session path of the active session.
 A session path `secrets-empty-path' indicates there is no open session.")

+(defvar secrets-session-algorithm "dh-ietf1024-sha256-aes128-cbc-pkcs7")
+
+(defvar secrets-session-aes-key nil)
+
+(defconst secrets-dh-prime #xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381FFFFFFFFFFFFFFFF)
+
+(defun secrets-integer-to-unibyte-string (integer length endian)
+  "Convert INTEGER to a unibyte string with LENGTH bytes.
+ENDIAN is `big' or `little'."
+  (let ((str (make-string length 0)))
+    (dotimes (i length)
+      (let ((n (mod integer 256)))
+        (setq integer (/ integer 256))
+        (setf (aref str i) n)))
+    (if (eq endian 'big)
+        (reverse str)
+      str)))
+
+(defun secret-unibyte-string-to-integer (str endian)
+  "Convert STR to an integer.
+ENDIAN is `big' or `little'."
+  (if (eq endian 'little)
+      (setq str (reverse str)))
+  (let ((num 0)
+        (len (length str)))
+    (dotimes (i len)
+      (setq num (+ (* num 256) (aref str i))))
+    num))
+
+(defun secrets-generate-key-pair ()
+  "Generate a key pair with Diffie-Hellman key agreement using `secrets-dh-prime'.
+Return a list like (PRIVATE-KEY PUBLIC-KEY)."
+  (require 'calc-math)
+  (let* ((calc-display-working-message nil)
+         (priv-key (random (ash 1 1024)))
+         (pub-key (math-pow-mod 2 priv-key secrets-dh-prime)))
+    (list
+     (secrets-integer-to-unibyte-string priv-key 128 'big)
+     (secrets-integer-to-unibyte-string pub-key 128 'big))))
+
+(defun secrets-compute-aes-key (priv-key peer-pub-key)
+  "Compute the symmetric key used for AES encryption."
+  (setq priv-key (secret-unibyte-string-to-integer priv-key 'big))
+  (setq peer-pub-key (secret-unibyte-string-to-integer peer-pub-key 'big))
+  (let* ((calc-display-working-message nil)
+         (shared-secret (math-pow-mod peer-pub-key priv-key secrets-dh-prime))
+         (shared-secret (secrets-integer-to-unibyte-string shared-secret 128 'big))
+         (salt (make-string 32 0))
+         (shared-key (gnutls-hash-mac "SHA256" salt shared-secret))
+         (aes-key (substring (gnutls-hash-mac "SHA256" shared-key "") 0 16)))
+    aes-key))
+
+(defun secrets-unibyte-string-to-byte-array (ustr)
+  "Convert unibyte string USTR to a D-Bus byte array."
+  (cons :array
+        (mapcan (lambda (byte)
+                  (list :byte byte))
+                ustr)))
+
 (defun secrets-close-session ()
   "Close the secret service session, if any."
   (dbus-ignore-errors
     (dbus-call-method
      :session secrets-service secrets-session-path
      secrets-interface-session "Close"))
-  (setq secrets-session-path secrets-empty-path))
+  (setq secrets-session-path secrets-empty-path
+        secrets-session-aes-key nil))

 (defun secrets-open-session (&optional reopen)
   "Open a new session with \"plain\" algorithm.
@@ -344,15 +404,78 @@ secrets-open-session
 returned, and it will be stored in `secrets-session-path'."
   (when reopen (secrets-close-session))
   (when (secrets-empty-path secrets-session-path)
-    (setq secrets-session-path
-	  (cadr
-	   (dbus-call-method
-	    :session secrets-service secrets-path
-	    secrets-interface-service "OpenSession" "plain" '(:variant "")))))
+    (let* ((key-pair (secrets-generate-key-pair))
+           (pub-key (secrets-unibyte-string-to-byte-array (cadr key-pair)))
+           (results
+            (condition-case err
+                (dbus-call-method
+                 :session secrets-service secrets-path
+                 secrets-interface-service "OpenSession"
+                 secrets-session-algorithm
+                 `(:variant ,pub-key))
+              (dbus-error
+               (unless (equal (cadr err) "org.freedesktop.DBus.Error.NotSupported")
+                 (signal (car err) (cdr err)))
+               (when (equal secrets-session-algorithm "plain")
+                 (signal (car err) (cdr err)))
+               (setq secrets-session-algorithm "plain")
+               (dbus-call-method
+                :session secrets-service secrets-path
+                secrets-interface-service "OpenSession"
+                secrets-session-algorithm
+                `(:variant "")))))
+           (peer-pub-key (apply #'unibyte-string (caar results))))
+      (when secrets-session-algorithm
+          (setq secrets-session-aes-key
+                (secrets-compute-aes-key (car key-pair) peer-pub-key)))
+      (setq secrets-session-path (cadr results))))
   (when secrets-debug
     (message "Secret Service session: %s" secrets-session-path))
   secrets-session-path)

+(defun secrets-pkcs7-pad (data)
+  "Pad DATA to 128 bits."
+  (let ((padding (- 16 (mod (length data) 16))))
+    (concat data (make-string padding padding))))
+
+(defun secrets-pkcs7-unpad (data)
+  "Remove padding bytes from DATA."
+  (let ((padding (aref data (1- (length data)))))
+    (if (<= padding 16)
+        (substring data 0 (- (length data) padding))
+      data)))
+
+(defun secrets-encrypt-secret (password)
+  "Encrypt PASSWORD. Return (IV ENCRYPTED-PASSWORD)."
+  (setq password (encode-coding-string password 'binary))
+  (pcase secrets-session-algorithm
+    ("dh-ietf1024-sha256-aes128-cbc-pkcs7"
+     (let* ((iv (random (ash 1 128)))
+            (iv (secrets-integer-to-unibyte-string iv))
+            (encrypted (gnutls-symmetric-encrypt
+                        "AES-128-CBC"
+                        (substring secrets-session-aes-key)
+                        (substring iv)
+                        (secrets-pkcs7-pad password))))
+       (reverse encrypted)))
+    ("plain"
+     (list "" password))))
+
+(defun secrets-decrypt-secret (iv encrypted-value)
+  "Decrypt ENCRYPTED-VALUE."
+  (let ((password
+         (pcase secrets-session-algorithm
+           ("dh-ietf1024-sha256-aes128-cbc-pkcs7"
+            (secrets-pkcs7-unpad
+             (car
+              (gnutls-symmetric-decrypt "AES-128-CBC"
+                                        (substring secrets-session-aes-key)
+                                        (substring iv)
+                                        (substring encrypted-value)))))
+           ("plain"
+            encrypted-value))))
+    (decode-coding-string password 'utf-8)))
+
 ;;; Prompts.

 (defvar secrets-prompt-signal nil
@@ -664,8 +787,7 @@ secrets-create-item
			       (:variant ,(append '(:array) props))))))
	     ;; Secret.
	     `(:struct :object-path ,secrets-session-path
-		       (:array :signature "y") ;; No parameters.
-		       ,(dbus-string-to-byte-array password)
+                       ,@(secret-encrypt-secret password)
			,secrets-struct-secret-content-type)
	     ;; Do not replace. Replace does not seem to work.
	     nil))
@@ -720,11 +842,12 @@ secrets-get-secret
 ITEM can also be an object path, which is used if contained in COLLECTION."
   (let ((item-path (secrets-unlock-item collection item)))
     (unless (secrets-empty-path item-path)
-      (dbus-byte-array-to-string
-       (nth 2
-	(dbus-call-method
-	 :session secrets-service item-path secrets-interface-item
-	 "GetSecret" :object-path secrets-session-path))))))
+      (let* ((secret (dbus-call-method
+                      :session secrets-service item-path secrets-interface-item
+                      "GetSecret" :object-path secrets-session-path)))
+        (secrets-decrypt-secret
+         (apply #'unibyte-string (nth 1 secret))
+         (apply #'unibyte-string (nth 2 secret)))))))

 (defun secrets-get-attributes (collection item)
   "Return the lookup attributes of item labeled ITEM in COLLECTION.