如何在命令行中安全存取密钥信息:以 OpenAI API 密钥为例

2023-05-18

A version of this article appears on May. 18, 2023 on SSPAI as a member-only post. Learn more or subscribe

The article is permitted to be self-archived in the version as originally submitted for publication on the author’s personal website under CC BY-NC 4.0 pursuant to § 5.2(b) of the SSPAI Fellowship Contributor Agreement.

大量涌现的 AI 项目引发了如何有效管理和取用 API 密钥的问题。每次复制粘贴过于麻烦,明文写进配置文件也不安全。但通过合理利用内置功能或第三方工具,就能用加密存储代替明文密钥,达到兼顾安全和便捷的目的。

AI 的持续火热带动了很多人尝鲜的兴趣。而要使用目前大量涌现的 AI 项目,一个非常普遍的前提条件就是填入自己的 API 密钥,从而调用 OpenAI 等商业服务提供的模型。

这就产生了如何有效管理和取用 API 密钥的问题。由于密钥都是随机而冗长的,当然不可能像密码那样凭记忆输入。网页端工具倒是可以用浏览器或第三方软件的密码管理功能填写;但很多 AI 工具都需要在命令行下运行。例如,OpenAI 的官方命令行工具、以及很多调用 GPT 的 Docker 项目,都要求将 OpenAI API 密钥作为环境变量才能运行。

对此,如果每次都临时复制粘贴,不仅非常麻烦,还会在终端历史记录中留下明文密钥内容,不符合安全原则。

OpenAI 的一篇帮助文档谈及了这个问题,指出可以将密钥内容存储在终端配置文件(例如 .zshrc)中;类似地,Docker 也支持通过 .env 等外部文件传入环境变量。但这只能解决问题的一半,仍然会导致密钥内容的明文存储。这种问题做法导致的安全隐患,正是 GitHub 提供仓库密文扫描功能的背景。

实际上,通过合理利用内置功能或借助第三方工具,就能用加密存储代替明文密钥,达到兼顾安全和便捷的目的。下面,我们就以存储和调用 OpenAI API 密钥为例,说明不同平台上的解决方案。当然,这些方法也适用于在命令行环境中存取任何登录密码或其他凭据信息。

macOS

如果你是苹果「全家桶」用户,习惯用系统自带的密码管理功能,大概知道可以通过内置的「钥匙串访问」(Keychain Access,位于 /Applications/Utilities 目录下)查看和管理密码。

所谓「钥匙串」,是指 macOS 用来加密存储凭据信息的容器,除了网站密码,还包括已登录 WiFi 的密码、第三方证书等。

但较少有人知道的是,并不是只有 Safari 浏览器或适配了钥匙串的第三方应用可以写入钥匙串,用户自己也可以手动添加任何想要加密保存的信息。

例如,如果要用钥匙串存储 OpenAI 的 API 密钥,可以在「钥匙串访问」app 中选择「文件」>「新建安全备注项」,在「名称」中填写 OPENAI_API_KEY,在「备注」中填写密钥内容。(之所以选择「安全备注」类型而不是「登录项」,是因为 API 密钥是一个可独立使用的凭据,不需要同时保存用户名。)

当然,添加和访问钥匙串也不是一定要通过「钥匙串访问」app,还可以完全在命令行中完成。相关的命令是 security(1),用其添加密码的一般语法是:

security add-generic-password [-s service] [-a account] [-D kind] [-w password]

其中,-s-a-w 三个必填项分别用来指定服务名称、用户名和密码。-D 是可选的,指该钥匙串项目的类型。

例如,如果想添加一条存储 OpenAI API 密钥的安全备注,可以运行:

security add-generic-password -D "secure note" -s "OPENAI_API_KEY" -a "OPENAI_API_KEY" -w

这里,我们用 -D 选项指定类型为 secure note,即安全备注(填写用户名对于安全备注其实是多余的,只是因为 security 不够灵活的语法要求才不得不填一个)。

此外,注意到命令中没有直接包含 API 密钥内容,最后直接以一个空的 -w 选项结尾了。这样运行的效果是弹出一行类似登录账户时的提示,要求当场输入密钥内容。这就避免了密钥明文出现在命令执行历史里,更加安全。

此后,但凡需要调用保存的密钥,运行:

security find-generic-password -s "OPENAI_API_KEY" -w

就会返回密钥的原文。

你可能会疑问为什么这个操作不需要任何授权。其实,根据钥匙串的规则,访问其内容一般确实需要密码,但一个程序访问它自己添加的内容除外。我们之前就是通过 security 命令添加的钥匙串,因此再用 security 命令来搜索和获取其内容,可以免输密码。

进一步延伸,但凡需要用到 OpenAI API 密钥的地方,都可以换成包含上述命令的subshell,即 $(security find-generic-password -s "OPENAI_API_KEY" -w)

例如,如果要为终端添加环境变量 OPENAI_API_KEY,只用在 .zshrc(或你所用其他终端的配置文件)中加入:

export OPENAI_API_KEY=$(security find-generic-password -s "OPENAI_API_KEY" -w)

这就避免了在配置文件中包含明文密钥。

类似地,在运行 Docker 等需要传递 API 密钥作为环境变量的场合,也可以用上述 subshell 代替密钥明文,例如

docker run \
    -e OPENAI_API_KEY=$(security find-generic-password -s "OPENAI_API_KEY" -w) \
    [OPTIONS ...] \
    IMAGE

就会将密钥内容作为 OPENAI_API_KEY 变量传给将要运行的 IMAGE

Linux

与 macOS 不同,常见的 Linux 发行版没有统一的内置凭据管理工具,但绝大多数发行版的官方软件源都收录了 pass(全称为 password-store)这款命令行工具。这也是被广泛推荐、符合 UNIX 简洁风格的解决方案。因此,本文也选择它作为 Linux 下的推荐方案来演示。

使用 pass 之前需要先安装:

# Install on Ubuntu/Debian
sudo apt-get install pass

# Install on Fedora/RHEL
sudo dnf install pass

(更多发行版的安装方式参见官网说明。)

pass 使用 GPG 密钥对来加密存储的信息。为此,需要首先生成一个 GPG 密钥:

gpg --full-generate-key

(如果你不清楚自己是否已经为别的用途生成过 GPG 密钥,可以运行 gpg --list-keys 来确认。)

根据界面提示选择密钥类型和有效期等设置(都可以回车直接使用默认值),并输入用户名、邮箱和口令等信息,看到如下输出即表明创建成功。

pub   ed25519 2023-05-18 [SC]
      A1B2C3D4A1B2C3D4A1B2C3D4A1B2C3D4A1B2C3D4
uid   John Appleseed <[email protected]>
sub   cv25519 2023-05-18 [E]

然后用如下命令初始化 pass 的存储库:

pass init GPG_ID

这里的 GPG_ID 可以是之前生成密钥时使用的邮箱地址,也可以是 Key ID,即上述输出信息第二行(称为 fingerprint)的最后 8 个(或 16 个,分别称为 Short ID 和 Long ID)字符,即 A1B2C3D4。因为一个邮箱可以创建多个密钥,为了避免混淆,建议优先使用 Key ID。

这时,就可以通过如下命令存储 OpenAI API 密钥:

pass insert openai.com/api_key

根据提示输入 API 密钥并回车确认。

pass 实际上将凭据加密后存储在 ~/.password-store 路径,而上面的 openai.com/api_key 指的就是凭据在该目录中的存储路径,一般可以使用 域名/用户名 的结构,便于后续查询,但你当然也可以用自己习惯的结构来整理。

你可以通过运行 pass 命令来确认添加完成,输出应该类似于:

Password Store
└── openai.com
    └── api_key

与上面 macOS 的例子类似,此后就可以通过运行 pass openai.com/api_key 来获得 API 密钥内容,或者用 $(pass openai.com/api_key) 来代替对密钥的明文引用。

Windows

我不太确定这段是不是多此一举,因为……可能没有多少人有闲情雅致去直接用 PowerShell 折腾 Python 和 Docker?总之,这里只简单说明可行方案,供有兴趣的读者自行探索。

首先,Windows 上确实有类似于 macOS「钥匙串访问」的功能,称为「凭据管理器」(Credential Manager)。当年,IE 浏览器的密码保存功能就是调用凭据管理器存储的。

但时过境迁,这个功能被遗忘在作为旧时代阑尾的控制面板中,可以在「用户账户」分类下找到,或者直接在开始菜单中搜索打开。从那个 Vista 时代的图标就可以看出,微软早就不准备继续更新凭据管理器了。

这篇教程比较完整地介绍了怎样用自带的 VaultCmd.exe 和第三方 PowerShell 模块 Credential Manager,在 PowerShell 中操作凭据管理器:Managing Saved Passwords Using Windows Credential Manager (WindowsOSHub)。

此外,微软后来又开发了一个 PowerShell 模块 SecretManagement,功能非常类似于上面介绍的 pass,而且可以通过插件支持 1Password、KeePass 等大量第三方格式的密码库,目前一直保持开发。

这篇文章介绍了如何安装和使用:PowerShell SecretManagement Module: Securely Manage Credentials and Secrets (WindowsOSHub)。

1Password

上面介绍的方案仍然有一个缺点:不支持同步。其中,security 命令只能访问 macOS 本地的钥匙串,而不支持 iCloud 钥匙串。pass 虽然在设计上就考虑了用户使用 git 等工具同步的需求,但对于大多数人显然还是麻烦了。

其实,如果你正好在用 1Password 作为密码管理工具,使用它的命令行版本 1Password CLI 就可以兼顾安全和同步。这个工具不仅可以访问云端同步的 1Password 密码库,而且有很多考虑到开发环境实际需要的细节功能,比 securitypass 用起来更方便。

(虽然 1Password 之前转订阅制被普遍批评不厚道,但好歹是把收入继续投入在了持续开发上。)

1Password CLI 支持多个平台。在 macOS 上,可以用 brew install --cask 1password/tap/1password-cli 安装;Linux 可以直接下载预编译的可执行文件到 PATH 中,也可以添加 1Password 维护的软件源,然后通过 apt 或 dnf 安装(具体参考 1Password 文档)。

安装好后,在 1Password 的设置中启用「开发者」页面下的「与 1Password CLI 连接」。

接着在终端运行 op signin,根据提示验证,就完成了 1Password CLI 的登录。

从 1Password CLI 读取密码主要有两种方法。第一种是使用 op item get 子命令,例如:

# 获取名为 OpenAI 记录项(大小写不敏感)的所有字段信息
op item get "openai"

# 获取 OpenAI 记录项下标签为 API Key 字段(大小写不敏感)的信息
op item get --fields label="api key"

这种方法比较像搜索,好处是语法简单,但不太精确,在有多个类似名称账户的场合容易混淆。

因此,对于 API 密钥这种需要准确引用的场合,建议使用 op read 子命令结合「密文引用链接」(secret reference)来读取。Secret reference 在形式上类似于自动化玩家熟悉的 URL Scheme,其结构为:

op://<vault-name>/<item-name>[/<section-name>]/<field-name>

例如,op://Personal/OpenAI/API Key 指的就是 OpenAI 记录项下,标签为 API Key 的字段(如无歧义,分组 [section] 部分可以省略)。当然,除了根据名称推断出引用链接,也可以使用 op item <item-name> --format json 命令,然后从输出的 JSON 中找到。

知道了引用链接,就可以通过:

op read "op://Personal/OpenAI/API Key"

获取 API 密钥的内容。

但与上述 macOS 的例子不同,注意尽量不要直接在配置文件中用 subshell 的形式调用 op run 命令,即不要在 ZSH 配置文件中这样写:

export OPENAI_API_KEY=$(op read "op://Personal/OpenAI/API Key")

这是因为 1Password CLI 访问密码库有数秒的延迟,隔一段时间还需要重新验证一次;将其放在终端启动阶段执行是毫无必要的拖慢。

作为替代,我们可以利用 op run 命令的另一功能,换成这样的写法:

export OPENAI_API_KEY="op://Personal/OpenAI/API Key"

也就是直接将引用链接作为环境变量。

这样,每逢遇到需要调用 OpenAI API 密钥的命令时,只要在其之前添加 op run --,1Password 就会 (1) 先将引用链接解析为实际密钥内容,然后 (2) 作为同名环境变量的值传递给要运行的命令。

例如,如果要使用 OpenAI 官方命令行工具列举当前可用的模型列表,就可以运行:

op run -- openai api engines.list

除了填充环境变量,op run 命令还可以用来解析配置文件或脚本中的引用链接。

例如,你准备部署一个提供了 Docker Compose 文件的项目,使用说明要求在外部文件 .env 中提供 OpenAI 密钥。那么,你可以不用在其中填写实际密钥内容,而是填写:

OPENAI_API_KEY="op://Personal/OpenAI/API Key"

然后,在部署时运行:

op run --env-file .env -- docker compose up -d

1Password 就会先将 .env 中的引用链接解析为实际密钥内容,然后把解析后的版本当作 .env 文件传递给 docker compose 来读取。