写在前面
整理开发环境的时候,我发现自己的 ~/.zshrc 里躺着好几行这样的东西:
export OPENAI_API_KEY=sk-xxxxxxxx
export HALO_TOKEN=pat_xxxxxxxx
export TAVILY_API_KEY=tvly-xxxxxxxx
平时用着确实方便,echo $OPENAI_API_KEY 随手就能拿到。但每次想备份一下 dotfiles,或者要把终端截图发出去,我都得先瞪大眼睛检查一遍,生怕哪个密钥跟着一起漏出去。
于是我花了个晚上,把这些密钥从明文配置里搬进了 macOS 的钥匙串(Keychain)。这篇记录一下过程,中间还踩了个特别反直觉的坑。
一、明文密钥到底危险在哪
先说清楚问题出在哪。~/.zshrc 就是个纯文本文件,这意味着:
备份即泄露:dotfiles 仓库、Time Machine 备份、同步网盘,全都会把这些密钥一起带上。
截图即泄露:调试时
cat ~/.zshrc,一屏截图发出去就完了。权限形同虚设:我顺手检查了一下,好几个
.env文件的权限是-rw-r--r--,也就是同一台机器上任何用户都能读。
看到那个 644 权限的时候我才意识到,"反正是本地文件所以无所谓"这个想法其实站不住脚。
二、思路:钥匙串做真源,环境变量做接口
macOS 自带 security 命令,可以直接读写钥匙串。我的整体思路是分两层:
这样配置文件里就再也不出现密钥了,只留一句 source。
读取函数很简单:
_zc_kc_get() {
local _v
_v=$(security find-generic-password -s "$1" -a "$2" -w 2>/dev/null) || return 1
[[ -n "$_v" ]] || return 1
print -r -- "$_v"
}
再把"环境变量名 → 钥匙串条目"做成一张映射表,启动时循环注入:
typeset -A _ZC_SECRETS=(
OPENAI_API_KEY zcode.openai
HALO_TOKEN zcode.halo.pat
)
for _name in ${(k)_ZC_SECRETS}; do
[[ -n "${(P)_name}" ]] && continue # 已有值就不覆盖
if _val=$(_zc_kc_get "${_ZC_SECRETS[$_name]}" default); then
export "$_name=$_val"
fi
done
而 ~/.zshrc 里只剩下:
[[ -f "$HOME/.secrets/load.zsh" ]] && source "$HOME/.secrets/load.zsh"
三、第一个坑:security -w 会把密钥暴露给 ps
写写入逻辑的时候,我本来打算这么写:
# 错误示范
security add-generic-password -s "zcode.openai" -a default -w "$VALUE" -U
看起来没问题吧?-w 后面跟上密钥值,一把梭。
结果翻了文档才反应过来:-w "$VALUE" 里的值会出现在进程的命令行参数(argv)里。也就是说,在这个瞬间,同机器上的任何进程执行一次 ps 都能读到它。
我做了个对照实验——同一把密钥、同样的调用频率,两种写法各跑一轮,用 ps 高频抓取:
差别很明显。
所以正确写法是让命令从标准输入进去,而不是走参数:
# 正确做法
security -i <<EOF
add-generic-password -s zcode.openai -a default -w "$VALUE" -U
EOF
为什么会这样? 因为 -w 的语义就是"把密码当参数传进去",而进程参数是公开的(ps 随时可查);换成 -i 交互模式后,整条命令从 stdin 读入,密钥始终没有落到 argv 上。
一个小细节:值里如果含双引号,得先转义,否则解析会出错。
四、第二个坑:Finder 启动的应用读不到环境变量
集中管理做得很顺,我一度想把这套用到所有配置上。然后就踩了第二个坑。
macOS 的应用从 Finder 或 Dock 启动时,不会继承 shell 的环境变量。
这意味着:那些用 ${VAR} 引用环境变量的应用配置,从终端启动它一切正常,从 Finder 双击就挂——因为那一刻变量根本不存在。
所以最后我的方案是分两层:
终端里跑的工具(CLI、脚本):走环境变量注入,完全集中化。
GUI 应用的自有配置:密钥只能留在它自己的配置文件里,但把权限收紧到
600,并且不放进任何同步目录。
不能为了"统一"而牺牲可用性,这点想明白了就不纠结了。
五、最终效果
现在 ~/.zshrc 里已经没有任何明文密钥了。验证方式是带超时地读变量长度,而不是打印值:
echo ${#OPENAI_API_KEY} # 输出长度,不泄露内容
密钥全部躺在钥匙串里,条目名统一带 zcode. 前缀,用一个小脚本管理:
secret put <名字> # 交互式录入,不回显
secret ls # 列出所有条目名(不显示值)
secret rm <名字> # 删除
顺手还写了个审计脚本,扫描常见位置有没有漏网的明文。它的输出只显示前 4 位和长度,这样连审计日志本身都不会泄露密钥。
跑完之后,扫描结果从"发现 4 处明文残留"变成了"未发现明文残留"。
六、小结
几点体会:
"本地文件所以无所谓"是个错觉。 644 权限加上各种备份链路,明文密钥的暴露面比想象中大得多。
密钥不进 argv 是个很容易被忽略的细节。
security -w这个坑,不看文档根本想不到。统一要服从可用性。 GUI 应用读不到环境变量这件事,让我放弃了"全部集中化"的执念——分层比强行统一更靠谱。
如果你也有一堆密钥躺在 .zshrc 里,不妨也花个晚上收拾一下。搬完之后,至少截图发出去的时候不用先检查一遍了。
默认评论
Halo系统提供的评论