10 年前の [2016-05-06-2] で、my-lisp-load という関数を紹介しました。アクセストークンなどを ~/.emacs.d/spec/ 配下のファイルに書いておき、init.el からはファイル名で読み込む関数です。

当時の目的は、init.el を GitHub に公開しても credential が漏れないようにすることでした。spec/ は git 管理外なので、この目的は今でも果たせています。

ただ、「git 管理外である」ことと「ストレージにない」ことは別の話です。spec/ にはトークンが平文のまま保存されているため、~/.emacs.d/ を丸ごと持っていくようなマルウェアには無力です。

2026 年の対策としては心もとないので、macOS の Keychain に移行しました。

なお以下は macOS 前提です。Linux なら secret-tool などに読み替えてください。

auth-source.el で Keychain から読むようにした

作ったのがこちらの my-secret-load 関数になります。

🔗 .emacs.d/init.el#L173-L186

(defun my-secret-load (name)
  "Load secret of NAME from macOS Keychain via auth-source

Register it beforehand as an internet password, since auth-source in
Emacs 31.1 cannot find generic passwords.  Putting -w last prompts for
the value, so it never appears in argv.  Add -U to update an existing
item.

  $ security add-internet-password -s NAME -a emacs -w"
  (require 'auth-source)
  (let ((auth-sources '(macos-keychain-internet))
        (auth-source-do-cache nil))
    (or (auth-source-pick-first-password :host name :user "emacs")
        (error "Cannot read %s from Keychain" name))))

my-secret-load は、Emacs 標準の auth-source.el にある auth-source-pick-first-password の薄いラッパーです。検索先を macOS Keychain の internet password に限定し、見つからなければエラーにしています。

auth-source.el はデフォルトで検索結果を 2 時間キャッシュします。ただ、見つからなかった結果もキャッシュされるため、Keychain に登録する前に一度呼んでいると、登録後もしばらく取得できません。そのため、キャッシュは無効にしています。

credential を読み込んでいた箇所は、my-lisp-load から my-secret-load に置き換えます。

;; before
(setq foo-access-token (my-lisp-load "foo-access-token"))

;; after
(setq foo-access-token (my-secret-load "foo-access-token"))

Keychain には事前にこのように登録しておきます。

$ security add-internet-password -s foo-access-token -a emacs -w

-a はアカウント名で、emacs に固定しています。my-secret-load の中で :user "emacs" として渡しているので、呼び出し側は名前だけを渡せば済みます。

-w を最後に指定すると、値を対話的に入力できます。-w "値" とは指定しないほうがよいです。~/.zsh_history などのシェル履歴に平文で残ってしまうためです。

保管先を Keychain にしたのは、私のパスワードマネージャが個人マシンは 1Password、業務マシンは Keeper と揃っていないためです。Keychain ならどちらのマシンでも同じ仕組みで済みます。

auth-source.el は Keychain 以外の保存先にも対応していますが、次の理由で採用しませんでした。

  • ~/.authinfo.gpg
    • 復号時にパスフレーズを求められる
    • Emacs の起動時に読み込むアクセストークンがあるため、gpg-agent のキャッシュが切れていると、Emacs を起動するたびにパスフレーズの入力が必要になる
  • pass CLI(auth-source-pass.el)
    • 中身は GPG なので、パスフレーズの手間は ~/.authinfo.gpg と同じ
    • 別途 pass CLI のインストールも必要になる
  • 1Password(auth-source-1password.el など)
    • サードパーティのパッケージと 1Password CLI が必要になる
    • 前述のとおり、業務マシンは Keeper なので揃わない

Keychain は macOS へのログイン時にロックが解除されるので、Emacs の起動時に何も入力せずに済みます。

また、Keychain にアクセスしているのは Emacs.app 自身ではなく、auth-source.el が呼び出す security CLI です。Keychain のアクセス許可はアプリごとに記録されるので、Emacs.app を更新しても、改めて許可を求められることはありません。

なお my-lisp-load は引き続き利用しています。user-mail-address などは credential ではないものの、個人マシンと業務マシンで値が異なり、あまり公開したくもないので、spec/ に残しています。

generic password は Emacs 31.1 では取得できない

Keychain のパスワードには、generic password と internet password の 2 種類があります。auth-source.el はどちらにも対応しています。アプリの設定値を入れるなら generic password の方が素直ですが、Emacs 31.1 では取得できませんでした。

(let ((auth-sources '(macos-keychain-generic))
      (auth-source-do-cache nil))
  (auth-source-pick-first-password :port "foo-access-token" :user "emacs"))
;; => nil

macos-keychain-generic では、security CLI の -s(サービス名)に渡したい値を :host ではなく :port に載せます。分かりにくい対応ですが、上のコードはこれに従っています。それでも nil が返ります。

:type が途中で取り除かれるため

lisp/auth-source.el を確認したところ、原因が分かりました。

auth-source-search は、バックエンドの search 関数に :type を付けて渡します。受け取った auth-source-macos-keychain-search は、実処理の auth-source-macos-keychain-search-items へ渡す前に、auth-source-search-spec で spec のキーを絞ります。このとき除外されるのが auth-source-ignored-keys です。

(defconst auth-source-ignored-keys
  '(:create :delete :max :backend :label :require :type)
  "List of meta keys to be ignored in data stores.")

:type が入っているので、ここで spec から取り除かれます。

一方、search-items はキーワード引数の type を見て、generic かどうかを判定しています。

(cl-defun auth-source-macos-keychain-search-items (coll _type _max
                                                        host port user
                                                   &key label type
                                                   &allow-other-keys)
  (let* ((keychain-generic (eq type 'macos-keychain-generic))

type は常に nil になるので、find-generic-password ではなく find-internet-password が実行されます。generic password が見つからないのは当然です。

実際、:type を明示して search-items を直接呼ぶと取得できました。

(auth-source-macos-keychain-search-items
 "default" nil 1 nil "foo-access-token" "emacs"
 :type 'macos-keychain-generic)
;; => ((:type macos-keychain-generic :secret ... ))

おそらく 31.1 でのエンバグ

Emacs 30.2 では、同じ generic password を auth-source.el で取得できました。30.2 までは、keychain バックエンドが自前で除外キーのリストを持っていて、:type はありませんでした。

;; Filter out ignored keys from the spec
(ignored-keys '(:create :delete :max :backend :label :host :port))

除外キーを絞るループは 3 箇所にコピペされていて、keychain バックエンドのものには FIXME コメントが付いていました。リストの中身はバックエンドごとに違い、他の 2 箇所には :type が含まれていました。それを a142cc26 (2025-10-05)で共通化した際に、keychain バックエンドにも :type が紛れ込んだようです。

コミットメッセージにも 31.1 の NEWS にも、generic password の扱いを変えたという記載はありません。auth-source-macos-keychain-search の docstring にも macos-keychain-generic の使用例が残っているので、意図した仕様変更とは考えにくく、おそらくエンバグだと思います。

macos-keychain-internet の方は、type が nil でも find-internet-password が実行されるので、結果として正しく取得できます。壊れているのは generic だけです。片方にしか影響が出なかったのも、見逃された一因だと思います。

debbugs を件名で検索した限りでは、報告は見当たりませんでした。一旦報告はせず、Emacs 側で直ったら追随するつもりです。

ということで、internet password として登録することにしました。

Keychain でも防げないもの

Keychain に移行して防げるのは、~/.emacs.d/ のファイルを持ち出すだけの相手までです。

同じユーザー権限でコマンドを実行できる相手は防げません。security find-internet-password -s foo-access-token -w を叩けば取れてしまいます。

Keychain にはアプリごとのアクセス制御もありますが、許可されているのは security CLI だけです。

キーチェーンアクセスのアクセス制御画面

そのため、Emacs から呼んだのか、シェルから呼んだのかは区別できません。

こうなると有効なのは、Touch ID のように毎回人間が介在する仕組みか、トークン自体の権限を絞り、有効期限を短くしておくことくらいです。盗まれる前提で、被害を小さくしておくのが現実的だと思います。

まとめ

~/.emacs.d/spec/ の平文ファイルに保存していた credential を、macOS Keychain に移行しました。

2016(my-lisp-load 2026(my-secret-load
守る相手 GitHub に公開したときの流出 ファイルを持ち出すマルウェア
やったこと init.el から値を追い出し、git 管理外のファイルに保存する ストレージに平文で保存するのをやめ、Keychain に移行する
残った課題 ストレージ上は平文のまま macOS 縛り。同じユーザー権限で security CLI を叩ける相手には防げない

10 年越しに、意図せず伏線を回収した形です。途中で Emacs 31.1 のエンバグらしきものにも遭遇しました。

Keychain に移行しても、同じユーザー権限でコマンドを実行できる相手には読まれます。トークンを Keychain に隠すことと、権限を絞り有効期限を短くすることを組み合わせた、多重防御が大事だと思います。

どなたかの参考になれば幸いです。

関連