在 age 和 GPG 之间做选择,就是在小巧、单一用途的加密工具与完整的 OpenPGP 实现之间做选择。age(格式为 age-encryption.org/v1,规范发布在 C2SP)把文件加密给一个或多个接收者,每个接收者都是一个简短的字符串,例如 age1…、SSH 公钥或后量子 age1pq1… 密钥;密钥封装使用 X25519 或混合 ML-KEM,载荷则用 ChaCha20-Poly1305 加密。GnuPG 实现的是 OpenPGP:加密、签名、密钥认证、密钥环、密钥服务器、智能卡以及电子邮件集成,背后是几十年积累下来的兼容性。
概览
| age | GnuPG(OpenPGP) | |
|---|---|---|
| 功能范围 | 仅文件加密 | 加密、签名、密钥认证、密钥管理 |
| 规范 | age v1(C2SP),只有一种格式,无需协商版本 | RFC 9580(2024)和 RFC 9980(2026);GnuPG 遵循 LibrePGP |
| 公钥 | 62 个字符(age1…);后量子公钥 1,959 个字符(age1pq1…) | 带用户 ID 和自签名的二进制密钥;Ed25519 加 Cv25519 为 473 字节(实测) |
| 算法 | X25519、ML-KEM-768 + X25519 混合、用于密码短语的 scrypt、ChaCha20-Poly1305、HKDF 和 HMAC-SHA-256 | 种类繁多:RSA、ECC、AES、Camellia,以及用于旧数据的传统加密算法;GnuPG 2.5 支持 ML-KEM-768 + X25519 |
| 配置 | 无 | 偏好设置、信任模型、算法和压缩设置 |
| 26 字节文件的实测密文大小 | 226 字节(X25519);1,685 字节(后量子) | 203 字节(Cv25519);1,288 字节(ML-KEM-768 + X25519 子密钥) |
| SSH 密钥用作接收者 | 支持(ssh-ed25519、ssh-rsa) | 不支持 |
| 硬件密钥 | 插件:YubiKey PIV、TPM、Secure Enclave | OpenPGP 智能卡和安全密钥 |
| 签名 | 按设计不提供 | 支持 |
| 密钥发现 | 无;接收者直接交换 | 密钥服务器、WKD、密钥环 |
| 大文件 | 以 64 KiB 的已认证块流式处理;从 1.3 起提供支持随机定位的解密 API | 流式处理;单个 AEAD 数据包,内部分块 |
用两种工具加密同一个文件
下面的脚本把一个 26 字节的机密分别加密给 age X25519 接收者、age 后量子接收者、GnuPG Cv25519 子密钥和 GnuPG ML-KEM-768 + X25519 子密钥,逐一解密并比较大小(age 1.3.2,GnuPG 2.5.21)。所有操作都在临时目录中进行。
#!/bin/sh
dir=$(mktemp -d /tmp/agegpg.XXXXXX) # short path: gpg-agent sockets need one
cd "$dir" || exit 1
export GNUPGHOME="$dir/g"
mkdir -m 700 g
printf 'db_password=correct-horse\n' > secret.env # 26 bytes
echo "== age"
age-keygen -o key.txt 2> /dev/null
recipient=$(age-keygen -y key.txt)
echo "recipient: ${#recipient} characters"
age -r "$recipient" -o secret.env.age secret.env
age -d -i key.txt secret.env.age | cmp -s - secret.env && echo "decrypted: identical"
echo "ciphertext: $(wc -c < secret.env.age | tr -d ' ') bytes"
echo "== age, post-quantum recipient"
age-keygen -pq -o pq.txt 2> /dev/null
pqrecipient=$(age-keygen -y pq.txt)
echo "recipient: ${#pqrecipient} characters"
age -r "$pqrecipient" -o secret.env.pq.age secret.env
age -d -i pq.txt secret.env.pq.age | cmp -s - secret.env && echo "decrypted: identical"
echo "ciphertext: $(wc -c < secret.env.pq.age | tr -d ' ') bytes"
echo "== GnuPG"
gpg --batch --quiet --passphrase '' --quick-gen-key "Ops <ops@example.org>" ed25519 cert 1y
fpr=$(gpg --list-keys --with-colons ops@example.org 2> /dev/null | awk -F: '/^fpr/ { print $10; exit }')
gpg --batch --quiet --passphrase '' --quick-add-key "$fpr" cv25519 encr 1y
gpg --batch --quiet --trust-model always -r ops@example.org -o secret.env.gpg -e secret.env
gpg --batch --quiet -d secret.env.gpg 2> /dev/null | cmp -s - secret.env && echo "decrypted: identical"
echo "ciphertext: $(wc -c < secret.env.gpg | tr -d ' ') bytes"
echo "public key export: $(gpg --export ops@example.org | wc -c | tr -d ' ') bytes binary"
echo "== GnuPG, ML-KEM-768 + X25519 subkey"
gpg --batch --quiet --passphrase '' --quick-gen-key "Ops PQ <ops-pq@example.org>" ed25519 cert 1y
fpr=$(gpg --list-keys --with-colons ops-pq@example.org 2> /dev/null | awk -F: '/^fpr/ { print $10; exit }')
gpg --batch --quiet --passphrase '' --quick-add-key "$fpr" ky768_cv25519 encr 1y
gpg --batch --quiet --trust-model always -r ops-pq@example.org -o secret.env.pq.gpg -e secret.env
gpg --batch --quiet -d secret.env.pq.gpg 2> /dev/null | cmp -s - secret.env && echo "decrypted: identical"
echo "ciphertext: $(wc -c < secret.env.pq.gpg | tr -d ' ') bytes"
gpgconf --kill gpg-agent
cd / && rm -rf "$dir"
== age
recipient: 62 characters
decrypted: identical
ciphertext: 226 bytes
== age, post-quantum recipient
recipient: 1959 characters
decrypted: identical
ciphertext: 1685 bytes
== GnuPG
decrypted: identical
ciphertext: 203 bytes
public key export: 473 bytes binary
== GnuPG, ML-KEM-768 + X25519 subkey
decrypted: identical
ciphertext: 1288 bytes
两者都会为每个接收者增加一段固定大小的头部,外加少量认证开销,因此对实际文件而言,差别可以忽略不计。后量子方案的额外开销是每个接收者节中携带的 ML-KEM-768 密文(1,088 字节);age 的这部分开销更大,主要是因为它的头部是文本格式,其中的二进制值用 base64 编码。脚本使用 --trust-model always 只是因为密钥在一秒前才刚刚创建;实际使用时,你应该核对接收者的指纹。
值得注意的互操作细节
对上面的文件运行 gpg --list-packets,可以看到两点值得了解的情况。对于 Cv25519 密钥,GnuPG 2.5 写出的是 OCB 模式的“AEAD encrypted packet”(LibrePGP 格式),而不是 RFC 9580 定义的第 2 版加密数据包,因为 GnuPG 自己生成的密钥声明支持这种格式;只遵循 RFC 9580 的实现可能无法读取这类文件。对于后量子子密钥,GnuPG 使用的是公钥算法 8,即它的 LibrePGP Kyber 组合算法,而 RFC 9980 为 ML-KEM-768 + X25519 分配的编号是 35,因此 GnuPG 的后量子密钥与 RFC 9980 密钥无法互通。age 只有一种格式和一种后量子构造(HPKE 搭配 ML-KEM-768 与 X25519 的混合方案,也称 X-Wing),并且公布了测试向量。
为什么 age 刻意保持小巧
age 去掉了所有让 OpenPGP 难以安全使用的东西:没有算法协商,没有压缩(压缩可能泄露明文的信息),没有未经认证的模式,没有密钥服务器,也没有信任网。接收者就是一个可以粘贴进配置文件的字符串;加密给多个接收者的文件,每个接收者对应一个节。代价是功能范围:age 不能签名,因此无法证明文件是谁加密的;它也不管理、不分发密钥。这些空白由周边工具来填补:SOPS 把 YAML 和 JSON 中的值加密给 age 接收者,插件则让身份可以存放在 YubiKey、TPM 或 macOS Secure Enclave 中。
在 OpenPGP 是共同语言的场合,GnuPG 的广度正是它的价值所在:与现有通信对象收发签名和加密的邮件、软件包仓库、下游验证者已经在检查的发布签名,以及同时存放签名密钥、加密密钥和身份验证密钥的 OpenPGP 智能卡。
如何选择
- Git 中的机密(SOPS)、CI 变量、备份、dotfile:age,每个人或每台机器一个接收者,另加一个硬件或离线的恢复接收者。
- 手头已有对方的 SSH 密钥,要加密给对方:age。
- 需要长期保密的归档:使用后量子
age1pq1接收者的 age,以抵御“先收集后解密”攻击。 - 加密邮件:OpenPGP(或 S/MIME,参见 S/MIME 与 OpenPGP 对比)。
- 为使用 OpenPGP 的接收者一步完成加密和签名:GnuPG。
- 发布签名和软件包签名:两者作为加密工具都不适用;参见 Sigstore 与 GPG 对比。
迁移说明
- 生成 age 身份(私钥):每个人或每台机器各一个(使用
age-keygen;等所有负责加密或解密的人都用上 age 1.3 或更高版本后,可改用age-keygen -pq),并离线保存一个恢复身份。 - 重新加密,而不是转换:用旧密钥解密每个 GPG 文件,再加密给新的接收者;不存在格式转换。对于 SOPS,把 age 接收者添加到
.sops.yaml的创建规则中,并对每个文件运行sops updatekeys;之后移除 PGP 密钥时,接着运行sops rotate,让数据密钥也随之更换。 - 保留 GPG 密钥,直到每个旧文件和备份都已重新加密或已过期。
- 单独替换 GPG 签名,改用 SSH 签名、minisign 或 Sigstore,因为 age 不提供签名。
- 检查大小和工具:使用 age 加密工具,并用 OpenPGP 查看器查看旧文件,找出它们使用了哪些密钥和算法。