agenix - 用于 NixOS 的 age 加密机密
agenix
是一个小巧便捷的 Nix 库,用于使用常见的公钥-私钥 SSH 密钥对安全地管理和部署机密:
你可以在源机器上使用多个公共 SSH 密钥加密一个机密(密码、访问令牌等),
然后将该加密的机密部署到任何拥有与这些公钥之一对应的私有 SSH 密钥的目标机器上。
该项目包含两个部分:
- 一个
agenix
命令行应用程序(CLI),用于将机密加密为可复制到 Nix 存储的安全.age
文件。 - 一个
agenix
NixOS 模块,方便地- 将这些加密的机密(
.age
文件)添加到 Nix 存储中,以便可以像任何其他 Nix 包一样使用nixos-rebuild
或类似工具部署。 - 在目标机器上使用该机器上的私有 SSH 密钥自动解密
- 自动将这些解密的机密挂载到一个众所周知的路径(如
/run/agenix/...
)以供使用。
- 将这些加密的机密(
目录
问题和解决方案
Nix 存储中的所有文件都可以被任何系统用户读取,因此不适合包含明文机密。许多现有工具(如 NixOps deployment.keys)与 nixos-rebuild
分开部署机密,这使得部署、缓存和审计更加困难。带外机密管理也不太可重现。
agenix
通过使用您预先存在的 SSH 密钥基础设施和 age
将机密加密到 Nix 存储中来解决这些问题。机密在 NixOS 系统激活期间使用 SSH 主机私钥解密。
特性
- 机密使用 SSH 密钥加密
- 通过
ssh-keyscan
获取系统公钥 - 可以使用 GitHub 上可用的用户公钥(例如,https://github.com/ryantm.keys)
- 通过
- 无需 GPG
- 代码量很少,便于审计
- 加密的机密存储在 Nix 存储中,因此不需要单独的分发机制
注意事项
- 密码保护的 ssh 密钥:由于 age 不支持 ssh-agent,密码保护的 ssh 密钥无法很好地工作。例如,如果你需要重新加密 20 个机密,你将不得不输入 20 次密码。
安装
通过 niv 安装
首先将其添加到 niv:
$ niv add ryantm/agenix
通过 niv 安装模块
然后在 configuration.nix
的 imports
列表中添加以下内容:
{
imports = [ "${(import ./nix/sources.nix).agenix}/modules/age.nix" ];
}
通过 niv 安装 CLI
要安装 agenix
二进制文件:
{
environment.systemPackages = [ (pkgs.callPackage "${(import ./nix/sources.nix).agenix}/pkgs/agenix.nix" {}) ];
}
通过 nix-channel 安装
以 root 身份运行:
$ sudo nix-channel --add https://github.com/ryantm/agenix/archive/main.tar.gz agenix
$ sudo nix-channel --update
通过 nix-channel 安装模块
然后在 configuration.nix
的 imports
列表中添加以下内容:
{
imports = [ <agenix/modules/age.nix> ];
}
通过 nix-channel 安装 CLI
要安装 agenix
二进制文件:
{
environment.systemPackages = [ (pkgs.callPackage <agenix/pkgs/agenix.nix> {}) ];
}
通过 fetchTarball 安装
通过 fetchTarball 安装模块
在您的 configuration.nix 中添加以下内容:
{
imports = [ "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/modules/age.nix" ];
}
或者使用固定版本:
{
imports = let
# 将此替换为实际的提交 ID 或标签
commit = "298b235f664f925b433614dc33380f0662adfc3f";
in [
"${builtins.fetchTarball {
url = "https://github.com/ryantm/agenix/archive/${commit}.tar.gz";
# 从 nix build 输出更新哈希值
sha256 = "";
}}/modules/age.nix"
];
}
通过 fetchTarball 安装 CLI
要安装 agenix
二进制文件:
{
environment.systemPackages = [ (pkgs.callPackage "${builtins.fetchTarball "https://github.com/ryantm/agenix/archive/main.tar.gz"}/pkgs/agenix.nix" {}) ];
}
通过 Flakes 安装
通过 Flakes 安装模块
{
inputs.agenix.url = "github:ryantm/agenix";
# 可选,模块不需要
#inputs.agenix.inputs.nixpkgs.follows = "nixpkgs";
# 可选,选择不下载 darwin 依赖项(在 Linux 上节省一些资源)
#inputs.agenix.inputs.darwin.follows = "";
outputs = { self, nixpkgs, agenix }: {
# 将 `yourhostname` 更改为您的实际主机名
nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem {
# 更改为您的系统:
system = "x86_64-linux";
modules = [
./configuration.nix
agenix.nixosModules.default
];
};
};
}
通过 Flakes 安装 CLI
您可以临时运行 CLI 工具而无需安装:
nix run github:ryantm/agenix -- --help
但您也可以将其永久添加到 NixOS 模块中 (将系统 "x86_64-linux" 替换为您的系统):
{
environment.systemPackages = [ agenix.packages.x86_64-linux.default ];
}
例如,在您的 flake.nix
文件中:
{
inputs.agenix.url = "github:ryantm/agenix";
# ...
outputs = { self, nixpkgs, agenix }: {
# 将 `yourhostname` 更改为您的实际主机名
nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
# ...
{
environment.systemPackages = [ agenix.packages.${system}.default ];
}
];
};
};
}
教程
-
您要部署机密的系统应该已经存在并运行
sshd
,以便它在/etc/ssh/
中生成了 SSH 主机密钥。 -
创建一个目录来存储机密和
secrets.nix
文件,用于列出机密及其公钥:$ mkdir secrets $ cd secrets $ touch secrets.nix
这个
secrets.nix
文件不会导入到您的 NixOS 配置中。 它只用于agenix
CLI 工具(示例如下)以了解用于加密的公钥。 -
将公钥添加到您的
secrets.nix
文件中:let user1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0idNvgGiucWgup/mP78zyC23uFjYq0evcWdjGQUaBH"; user2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILI6jSq53F/3hEmSs+oq9L4TwOo1PrDMAgcA1uo1CCV/"; users = [ user1 user2 ];
system1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE"; system2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzxQgondgEYcLpcPdJLrTdNgZ2gznOHCAxMdaceTUT1"; systems = [ system1 system2 ]; in { "secret1.age".publicKeys = [ user1 system1 ]; "secret2.age".publicKeys = users ++ systems; }
这些是稍后能够使用其对应私钥解密 `.age` 文件的用户和系统。
你可以从以下方式获取公钥:
* 通常在本地计算机的 `~/.ssh` 目录下,例如 `~/.ssh/id_ed25519.pub`。
* 从运行中的目标机器使用 `ssh-keyscan`:
```ShellSession
$ ssh-keyscan <ip-地址>
... ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzxQgondgEYcLpcPdJLrTdNgZ2gznOHCAxMdaceTUT1
...
- 从 GitHub 获取,如 https://github.com/ryantm.keys。
-
创建一个秘密文件:
$ agenix -e secret1.age
它将在你的 $EDITOR 环境变量配置的应用程序中打开一个临时文件。 当你保存该文件时,其内容将使用
secrets.nix
文件中提到的所有公钥进行加密。 -
将秘密添加到 NixOS 模块配置中:
{ age.secrets.secret1.file = ../secrets/secret1.age; }
当
age.secrets
属性集包含一个秘密时,agenix
NixOS 模块稍后会自动解密并将该秘密挂载到默认路径/run/agenix/secret1
下。 这里secret1.age
文件成为你的 NixOS 部署的一部分,即移动到 Nix store 中。 -
在你的配置中引用秘密的挂载路径:
{ users.users.user1 = { isNormalUser = true; passwordFile = config.age.secrets.secret1.path; }; }
你可以在其他配置中已经引用(稍后)未加密秘密的挂载路径。 所以默认情况下
config.age.secrets.secret1.path
将包含路径/run/agenix/secret1
。 -
像往常一样使用
nixos-rebuild
或其他部署工具。secret1.age
文件将像任何其他 Nix 包一样被复制到目标机器。 然后它将被解密并按照先前描述的方式挂载。 -
编辑秘密文件:
$ agenix -e secret1.age
它假设你的 SSH 私钥在
~/.ssh/
中。 为了解密并打开一个.age
文件进行编辑,你需要用于加密该文件的公钥之一对应的私钥。你可以使用-i
明确传递你想使用的私钥,例如$ agenix -e secret1.age -i ~/.ssh/id_ed25519
参考
age
模块参考
age.secrets
age.secrets
是秘密的属性集。你总是需要使用这个配置选项。默认为 {}
。
age.secrets.<name>.file
age.secrets.<name>.file
是此秘密的加密 .age
文件的路径。这是唯一必需的秘密选项。
示例:
{
age.secrets.monitrc.file = ../secrets/monitrc.age;
}
age.secrets.<name>.path
age.secrets.<name>.path
是秘密解密后的路径。默认为 /run/agenix/<name>
(config.age.secretsDir/<name>
)。
定义不同路径的示例:
{
age.secrets.monitrc = {
file = ../secrets/monitrc.age;
path = "/etc/monitrc";
};
}
对于许多服务,你不需要设置这个。相反,在你的配置中使用 config.age.secrets.<name>.path
引用解密路径。
引用路径的示例:
{
users.users.ryantm = {
isNormalUser = true;
passwordFile = config.age.secrets.passwordfile-ryantm.path;
};
}
builtins.readFile 反模式
{
# 不要这样做!
config.password = builtins.readFile config.age.secrets.secret1.path;
}
这可能会导致明文被放置到全局可读的 Nix store 中。相反,让你的服务在运行时读取明文路径。
age.secrets.<name>.mode
age.secrets.<name>.mode
是解密后的秘密的权限模式,格式为 chmod 可以理解的格式。通常,你只需要与 age.secrets.<name>.owner
和 age.secrets.<name>.group
结合使用。
示例:
{
age.secrets.nginx-htpasswd = {
file = ../secrets/nginx.htpasswd.age;
mode = "770";
owner = "nginx";
group = "nginx";
};
}
age.secrets.<name>.owner
age.secrets.<name>.owner
是解密文件所有者的用户名。通常,你只需要与 age.secrets.<name>.mode
和 age.secrets.<name>.group
结合使用。
示例:
{
age.secrets.nginx-htpasswd = {
file = ../secrets/nginx.htpasswd.age;
mode = "770";
owner = "nginx";
group = "nginx";
};
}
age.secrets.<name>.group
age.secrets.<name>.group
是解密文件的组名。通常,你只需要与 age.secrets.<name>.owner
和 age.secrets.<name>.mode
结合使用。
示例:
{
age.secrets.nginx-htpasswd = {
file = ../secrets/nginx.htpasswd.age;
mode = "770";
owner = "nginx";
group = "nginx";
};
}
age.secrets.<name>.symlink
age.secrets.<name>.symlink
是一个布尔值。如果为 true(默认值),秘密将被符号链接到 age.secrets.<name>.path
。如果为 false,秘密将被复制到 age.secrets.<name>.path
。通常,你希望保持为 true,因为它可以安全地清理不再使用的秘密。(符号链接仍然存在,但它将是断开的。)如果为 false,你需要负责在停止使用秘密后自行清理。
一些程序不喜欢跟随符号链接(例如 Java 程序如 Elasticsearch)。
示例:
{
age.secrets."elasticsearch.conf" = {
file = ../secrets/elasticsearch.conf.age;
symlink = false;
};
}
age.secrets.<name>.name
age.secrets.<name>.name
是解密后文件的名称字符串。默认为属性路径中的 <name>
,但如果你希望文件名与属性名不同,可以单独设置。
一个秘密名称与其属性路径不同的示例:
{
age.secrets.monit = {
name = "monitrc";
file = ../secrets/monitrc.age;
};
}
age.ageBin
age.ageBin
是 age
二进制文件路径的字符串。通常,你不需要更改这个。默认为 age/bin/age
。
覆盖 age.ageBin
的示例:
{pkgs, ...}:{
age.ageBin = "${pkgs.age}/bin/age";
}
age.identityPaths
age.identityPaths
是一个尝试用于解密秘密的接收者密钥路径列表。默认情况下,它是 config.services.openssh.hostKeys
中的 rsa
和 ed25519
密钥,在 NixOS 上你通常不需要更改这个。列表项应该是字符串("/path/to/id_rsa""),而不是 nix 路径(
../path/to/id_rsa),因为后者会将你的私钥复制到 nix store 中,这正是
agenix设计用来避免的情况。在运行时,至少一个文件路径必须存在并能够解密相关的秘密。覆盖
age.identityPaths` 的示例:
{
age.identityPaths = [ "/var/lib/persistent/ssh_host_ed25519_key" ];
}
age.secretsDir
age.secretsDir
是默认情况下秘密被符号链接到的目录。通常情况下,你不需要更改这个设置。默认值为 /run/agenix
。
覆盖 age.secretsDir
的示例:
{
age.secretsDir = "/run/keys";
}
age.secretsMountPoint
age.secretsMountPoint
是在秘密被符号链接之前创建秘密世代的目录。通常情况下,你不需要更改这个设置。默认值为 /run/agenix.d
。
覆盖 age.secretsMountPoint
的示例:
{
age.secretsMountPoint = "/run/secret-generations";
}
agenix 命令行界面参考
agenix - 编辑和重新加密 age 秘密文件
agenix -e 文件 [-i 私钥]
agenix -r [-i 私钥]
选项:
-h, --help 显示帮助
-e, --edit 文件 使用 $EDITOR 编辑文件
-r, --rekey 使用指定的接收者重新加密所有秘密
-d, --decrypt 文件 将文件解密到标准输出
-i, --identity 解密时使用的身份
-v, --verbose 详细输出
文件 一个 age 加密的文件
私钥 用于解密文件的 SSH 私钥路径
EDITOR 环境变量,用于指定编辑文件时使用的编辑器
如果标准输入不是交互式的,EDITOR 将被设置为 "cp /dev/stdin"
RULES 环境变量,包含指定接收者公钥的 Nix 文件路径。
默认为 './secrets.nix'
重新加密
如果你更改了 secrets.nix
中的公钥,你应该重新加密你的秘密:
$ agenix --rekey
要重新加密一个秘密,你必须能够解密它。由于 age
加密算法中的随机性,即使身份没有改变,文件在重新加密时也总是会发生变化。(这最终可以通过从 age 文件中读取身份来改进。)
覆盖 age 二进制文件
agenix 命令行界面默认使用 age
作为其 age 实现,你可以使用 Flakes 来使用 rage
实现,如下所示:
{pkgs,agenix,...}:{
environment.systemPackages = [
(agenix.packages.x86_64-linux.default.override { ageBin = "${pkgs.rage}/bin/rage"; })
];
}
社区和支持
支持和开发讨论可以在 GitHub 上进行,也可以通过 Matrix 进行。
威胁模型/警告
本项目尚未经过安全专业人员的审核。
不熟悉 age
的人可能会惊讶地发现秘密没有经过身份验证。这意味着每个拥有秘密文件写入权限的攻击者都可以修改秘密,因为公钥是公开的。这乍看之下似乎不是问题,因为更改配置本身就可以轻易暴露秘密。然而,审查配置更改比审查随机秘密(例如,4096 位 RSA 密钥)更容易。这个问题可以通过使用消息认证码(MAC)来解决,就像其他实现(如 GPG 或 sops)那样,但为了简单起见,age
中省略了这一功能。
此外,你应该只加密那些在未来被解密时你能够使其失效的秘密,并准备定期轮换它们,因为 age 在 2024 年 6 月 19 日之前不是后量子安全的。因此,如果威胁行为者可以访问你加密的密钥(例如通过在公共仓库中使用),他们可以利用现在收集,以后解密的策略来存储你的密钥,以便日后解密,包括发现重大漏洞导致秘密暴露的情况。详情请参见 https://github.com/FiloSottile/age/issues/578。
贡献
- 主分支受保护,不允许直接推送
- 所有更改必须通过 GitHub PR 审核并获得至少一个批准
- PR 标题和提交消息应该至少以以下类别之一为前缀:
- contrib - 改进项目开发的事项
- doc - 文档
- feature - 新功能
- fix - 错误修复
- 请为新功能更新或制作集成测试
- 使用
nix fmt
来格式化 nix 代码
测试
你可以使用以下命令运行测试:
nix flake check
你可以以交互模式运行集成测试,如下所示:
nix run .#checks.x86_64-linux.integration.driverInteractive
启动后,输入 run_tests()
来运行测试。
致谢
本项目基于 Mic92 创建的 sops-nix。感谢 Mic92 的灵感和建议。