应用程序不应要求在配置文件中包含密码、API 密钥或令牌。

配置描述行为。它属于 git、代码审查、缺陷报告和开发者机器。

密钥授予权限。它需要受限访问和独立轮换。

将两者放在一个文件中会将不同的生命周期和受众耦合在一起。如果轮换密码需要重新生成应用程序配置,那么接口就把它们耦合得过于紧密了。

NixOS 包含 110 个针对此问题的变通方案

Section titled “NixOS contains 110 workarounds for this”

我们在 nixpkgs 的提交 141f212审计了所有处理真实密钥的 445 个 NixOS 模块,并根据密钥值的最终位置对每个模块进行了分类。

密钥值的最终位置模块数量占比
在运行时合并到配置文件中11025%
内联到 /nix/store 中的配置429%
作为环境变量传递16136%
留在由应用程序打开的专用文件中5813%
通过 systemd 凭据加载5312%
作为命令行参数传递194%
分类不确定2

值得关注的是 110 这个数字。四分之一的模块安全地获取密钥,然后将其复制到配置中,因为这是应用程序唯一接受的接口。

这些模块使用 envsubstreplace-secretjqyqsed 或自定义代码在启动时组装一个受限文件。结果可能是安全的,但每个模块现在都拥有特定于应用程序的安全敏感胶水代码,只是为了组合两个本应保持分离的输入。

这并非 NixOS 独有的问题。同样的变通方案在其他平台上以入口脚本、Helm 模板、初始化容器或 CI 插值步骤的形式出现。

附带说明一下,有 42 个模块可以将密钥内联到全局可读的 /nix/store 中。这个直接的安全问题已在 nixpkgs issue #24288 中跟踪。110 个运行时合并器则提出了更广泛的问题:即使部署作者避免了泄漏,缺少分离仍然会产生工作。

为密钥提供自己的接口

Section titled “Give secrets their own interface”

应用程序应通过专用的运行时通道接受密钥值,例如:

  • password_filetoken_file 设置;
  • systemd 凭据;
  • 范围狭窄的环境变量;
  • 或外部密钥提供程序。

这些机制的安全性并不相同:环境变量可以被继承,参数可能出现在进程列表中,文件仍然需要正确的权限。分离所保证的是,部署者不再需要制造第二个带有密钥的配置版本。

原理很简单;跨环境实现它却不简单。本地开发可能使用系统钥匙串,CI 环境可能使用环境变量,生产环境可能使用 1Password 或 Vault。如果没有共享的抽象,每个环境都需要自己的命名、查找、验证和注入胶水。

我在 Cachix 中犯过的错误

Section titled “How I got it wrong in Cachix”

Cachix 历史上将身份验证令牌和每个缓存的签名密钥存储在 ~/.config/cachix/cachix.dhall 中,与缓存名称和其他配置放在一起。这很方便,但该文件必须被视为密钥,即使其中大部分是普通配置。

一个典型的文件直接将它们混合在一起:

{ authToken = "XXX-AUTH-TOKEN"

, binaryCaches =

[ { name = "mycache"

, secretKey = "XXX-SIGNING-KEY"

}

]

}

缓存名称是配置;身份验证令牌和签名密钥是密钥。你无法在不共享凭据的情况下共享缓存配置。

devenv 2.2 通过 SecretSpec 分离令牌。 项目声明 CACHIX_AUTH_TOKEN,devenv 从配置的提供程序解析它,并将该值传递给 Cachix,而不将其添加到 devenv 的配置中。

Cachix PR #737 通过 SecretSpec Haskell SDK 将相同的边界引入客户端。它从 SecretSpec 解析 CACHIX_AUTH_TOKENCACHIX_SIGNING_KEY,并可以将它们存储在用户选择的提供程序中,而不是 cachix.dhall。现有的环境变量和配置文件仍然是兼容性的更高优先级回退。 该 PR 仍处于开放状态,尚未在已发布的 Cachix 版本中提供。

这就是 SecretSpec 旨在解决的问题:配置声明需求,而每个环境选择值所在的位置。

声明一次,随处解析

Section titled “Declare once, resolve anywhere”

SecretSpec 通过将 secretspec.toml 作为应用程序所需内容的声明来应用这种分离,而不存储值:

[project]

name = "myapp"

[profiles.production]

DATABASE_URL = { description = "Postgres connection string" }

STRIPE_API_KEY = { description = "Stripe secret key" }

Providers 决定值所在的位置。开发者可以使用系统钥匙串,CI 可以使用环境变量,生产环境可以使用 1Password、Vault/OpenBao 或云密钥管理器,而无需更改声明。

现有应用程序可以在启动时接收解析后的值:

secretspec run -- ./myapp

应用程序还可以通过 SecretSpec SDKs 直接解析这些值,支持 Rust、Python、Go、Ruby、Node.js/TypeScript、Haskell、PHP 和 C#,所有语言都共享相同的解析器,因此行为在不同语言间保持一致。

提供程序负责密钥值的来源。SDK 为应用程序提供了使用它们的惯用方式。配置仍然是所需内容的共享声明。

使边界实用化

Section titled “Making the boundary practical”

如果你维护一个应用程序,请停止将密码和令牌添加到普通配置模式中。接受文件引用、凭据、环境变量或提供程序。

对于 NixOS,SecretSpec issue #65 跟踪如何通过官方集成声明和解析密钥,而无需每个模块的替换胶水。

跨开发者机器、CI 和生产环境的一致密钥处理过去需要只有专用平台团队才能构建的基础设施。任何规模的项目都应该能够在不首先构建自己的密钥平台的情况下分离密钥和配置。