SOPS、age 与 GPG:让加密配置可审查、可轮换、可退出
一次离职交接中,平台团队从 .sops.yaml 删除了成员的 age recipient,重新执行 sops updatekeys,当前分支也只剩团队接收者。验证人员用离职成员的旧 identity 解密最新文件,命令如期失败,于是工单被标记为“权限已回收”。几天后,审计人员从旧提交取出同一份密文,仍然成功解密;更糟的是,配置里的数据库密码从未轮换。团队撤掉的只是未来版本中的一个解密入口,并没有收回已经复制的 identity、历史密文和曾经出现过的明文。
加密配置的工程问题因此不能压缩成“文件是不是密文”。真正需要核对的是:谁生成内容数据密钥,哪些 recipient 能解开它,接收者变化是否重包裹或更换了数据密钥,明文在哪个进程和文件系统中出现,Git 历史、CI artifact、编辑器备份和离职设备还保留哪些副本,以及底层业务 secret 是否已经撤销。SOPS、age 与 GPG 分别处理这条链上的不同对象,组合使用时必须把边界分开。
三层对象不能混成一把“加密密钥”
age 和 GPG 都能独立加密文件。age 把公开的 recipient 与私密的 identity 分开;GPG 把公开主钥、用户标识、加密子钥、私钥材料和本地信任数据库组织在 OpenPGP keyring 中。它们解决的是“把一个文件加密给谁”。
SOPS 位于更上一层。它为每个文件生成一个随机 data key,用 data key 加密 YAML、JSON、ENV、INI 等结构化文件中的叶子值,再用 age recipient、PGP key 或云 KMS 等 master key 包裹 data key。Git 保存的是加密值和 sops metadata;recipient 的 identity 并不进入文件。授权者解密时先解开 data key,再验证完整性并恢复各个值。
这三层带来三个不同动作:增加或删除 recipient 是改变谁能解开 data key;updatekeys 按新规则重包裹现有 data key;rotate 生成新 data key 并重新加密文件值。底层数据库密码、API token 或证书私钥是否轮换,则是第四个动作。只做其中一个,不能声称整条凭据链已经撤销。
安装入口先固定来源、摘要和实际可执行文件
SOPS 的稳定二进制、校验文件、Sigstore bundle、SBOM 和容器镜像由 getsops/sops Releases 发布;age 的二进制、Sigsum 证明与格式入口在 FiloSottile/age;GnuPG 在 Linux 通常由发行版提供,Windows 可从 GnuPG 下载页进入 Gpg4win 或 GnuPG 安装包。不要把搜索广告、个人镜像和来源不明的“一键安装脚本”当成等价入口。
macOS 开发机可以从已批准的 Homebrew 源安装并立即记录版本:
brew install sops age gnupg
command -v sops age age-keygen gpg
sops --version
age --version
gpg --versionDebian、Ubuntu、Fedora、Alpine 等环境应优先使用组织批准的软件源;如果发行版中的 SOPS 或 age 太旧,改用官方 release artifact,并校验 release 页面公布的摘要和签名材料。CI 不应每次动态下载 latest,而应把批准版本、平台、架构和 SHA-256 固定在工具清单或基础镜像中。
Windows 上可以使用 Gpg4win 与 age 的 winget 清单,SOPS 则从官方 release 选择目标架构并验证摘要。下面的占位值必须来自同一份批准记录,不能临时凭网页文件名猜测:
winget install --exact --id FiloSottile.age
winget install --exact --id GnuPG.Gpg4win
$SopsVersion = '<APPROVED_SOPS_VERSION>'
$ExpectedSha256 = '<SHA256_FROM_THE_SAME_OFFICIAL_RELEASE>'
$Binary = Join-Path $env:LOCALAPPDATA 'Programs\sops\sops.exe'
# 下载步骤由组织制品代理完成后再校验;摘要不匹配就停止。
$Actual = (Get-FileHash -LiteralPath $Binary -Algorithm SHA256).Hash.ToLowerInvariant()
if ($Actual -ne $ExpectedSha256.ToLowerInvariant()) {
throw 'SOPS binary digest mismatch'
}
Get-Command sops, age, age-keygen, gpg | Format-Table Name, Source
sops --version
age --version
gpg --version版本输出只是第一层证据。还要记录可执行文件绝对路径,避免 PATH 中旧二进制抢先;验证代理、企业 CA 和制品镜像是否同时覆盖下载器与容器构建;卸载时清点用户配置目录、GPG home、age identity、编辑器插件缓存和 CI 镜像,而不是只删除 CLI。
age:先独立跑通 recipient 与 identity
age 没有 GPG 那样的全局 keyring 与信任数据库。age-keygen 生成 identity,age-keygen -y 从 identity 导出 recipient;加密端只需要 recipient,解密端必须持有匹配 identity。-r 可以重复指定 recipient,-R 从文件读取 recipient,-i 指定解密 identity。完整命令语义与 SSH key、口令文件边界见 age 官方仓库的 Usage。
下面的实验只在临时目录生成身份和随机合成值,退出时清理。identity 文件权限过宽会立即中止:
set -eu
LAB_ROOT="$(mktemp -d)"
cleanup() { chmod -R u+rwX "$LAB_ROOT" 2>/dev/null || true; rm -rf "$LAB_ROOT"; }
trap cleanup EXIT INT TERM
umask 077
age-keygen -o "$LAB_ROOT/alice.identity"
age-keygen -o "$LAB_ROOT/bob.identity"
age-keygen -o "$LAB_ROOT/outsider.identity"
age-keygen -y "$LAB_ROOT/alice.identity" > "$LAB_ROOT/alice.recipient"
age-keygen -y "$LAB_ROOT/bob.identity" > "$LAB_ROOT/bob.recipient"
age-keygen -y "$LAB_ROOT/outsider.identity" > "$LAB_ROOT/outsider.recipient"
SYNTHETIC_VALUE="$(head -c 24 /dev/urandom | base64 | tr -d '\n')"
printf 'service=catalog\nsecret=%s\n' "$SYNTHETIC_VALUE" > "$LAB_ROOT/config.env"
age -R "$LAB_ROOT/alice.recipient" \
-o "$LAB_ROOT/config.env.age" "$LAB_ROOT/config.env"
age --decrypt -i "$LAB_ROOT/alice.identity" \
-o "$LAB_ROOT/config.decrypted.env" "$LAB_ROOT/config.env.age"
cmp "$LAB_ROOT/config.env" "$LAB_ROOT/config.decrypted.env"
echo 'AGE_ROUND_TRIP_OK'预期看到 AGE_ROUND_TRIP_OK,而不是把明文打印到终端。密文文件可进入 Git,alice.identity、原始明文和解密副本都不能进入仓库、artifact 或 shell tracing。
SOPS 使用 age 时还会按平台查找用户配置目录中的默认 keys.txt,也可以通过 SOPS_AGE_KEY_FILE、SOPS_AGE_KEY 或 SOPS_AGE_KEY_CMD 提供 identity。默认发现便于个人开发,却可能让一把长期 identity 无意间解开多个仓库;生产自动化应显式指定身份来源,把文件权限、执行主体和仓库/环境授权一起约束,并验证错误 runner 没有从用户目录“捡到”可用 identity。环境变量中的 identity 同样会暴露给子进程和诊断工具,不能因为没有落成普通文件就视为无残留。
把 outsider identity 用于同一密文,会稳定暴露授权边界:
set +e
age --decrypt -i "$LAB_ROOT/outsider.identity" \
-o "$LAB_ROOT/should-not-exist" "$LAB_ROOT/config.env.age" \
2> "$LAB_ROOT/outsider.stderr"
rc=$?
set -e
test "$rc" -ne 0
test ! -s "$LAB_ROOT/should-not-exist"
grep -E 'no identity matched|incorrect identity' "$LAB_ROOT/outsider.stderr"
echo 'AGE_DENY_OK'失败证据应同时包含非零退出码、没有可用明文输出、错误属于“无匹配 identity”。网络、文件权限和损坏密文是不同故障,不能都归类为“密钥不对”。多接收者不是阈值授权:重复 -r 后,任意一个匹配 identity 都能独立解密。
ALICE_RECIPIENT="$(cat "$LAB_ROOT/alice.recipient")"
BOB_RECIPIENT="$(cat "$LAB_ROOT/bob.recipient")"
age -r "$ALICE_RECIPIENT" -r "$BOB_RECIPIENT" \
-o "$LAB_ROOT/shared.age" "$LAB_ROOT/config.env"
age -d -i "$LAB_ROOT/bob.identity" \
-o "$LAB_ROOT/shared.by-bob" "$LAB_ROOT/shared.age"
cmp "$LAB_ROOT/config.env" "$LAB_ROOT/shared.by-bob"age plugin 是独立可执行程序与 age CLI 之间的文本协议扩展。插件可以把解密能力接到硬件令牌、远端代理或其他 recipient 类型,但插件本身进入了可信计算基。使用插件 recipient 或 identity 时才会显式加载对应插件;安装了插件不等于所有文件自动交给它。插件二进制的来源、版本、PATH 优先级、设备确认策略和失败降级必须单独审查,不能因为底层格式仍叫 age 就忽略供应链边界。相关协议与插件加载原则见 age 插件规范。
GPG:公私钥、指纹、信任与子钥各管一件事
GPG 的对象比 age 多。primary key 通常承担认证和证书签发能力,encryption subkey 承担日常加密;user ID 把名称或邮箱声明绑定到 key;fingerprint 是核验具体公钥材料的稳定标识;ownertrust 是本机对“这个 key 的持有者有多大资格为别人做证明”的判断。拥有一份公钥、确认它的 fingerprint、认可 user ID 绑定、授予 ownertrust 是四件不同的事。
以下实验把 GNUPGHOME 放进临时目录,不接触用户真实 keyring。primary key 只保留认证能力,再增加独立加密子钥:
set -eu
LAB_ROOT="$(mktemp -d)"
trap 'chmod -R u+rwX "$LAB_ROOT" 2>/dev/null || true; rm -rf "$LAB_ROOT"' EXIT INT TERM
umask 077
OWNER_HOME="$LAB_ROOT/owner-gnupg"
CALLER_HOME="$LAB_ROOT/caller-gnupg"
OUTSIDER_HOME="$LAB_ROOT/outsider-gnupg"
mkdir -m 700 "$OWNER_HOME" "$CALLER_HOME" "$OUTSIDER_HOME"
SYNTHETIC_UID='Config Owner <config-owner@example.invalid>'
gpg --homedir "$OWNER_HOME" --batch --pinentry-mode loopback --passphrase '' \
--quick-generate-key "$SYNTHETIC_UID" ed25519 cert 30d
PRIMARY_FPR="$(gpg --homedir "$OWNER_HOME" --with-colons --fingerprint \
"$SYNTHETIC_UID" | awk -F: '$1=="fpr" {print $10; exit}')"
gpg --homedir "$OWNER_HOME" --batch --pinentry-mode loopback --passphrase '' \
--quick-add-key "$PRIMARY_FPR" cv25519 encr 30d
gpg --homedir "$OWNER_HOME" --with-colons --with-subkey-fingerprint \
--list-keys "$PRIMARY_FPR" > "$LAB_ROOT/key-structure.txt"
grep '^fpr:' "$LAB_ROOT/key-structure.txt"空 passphrase 只适合这次即刻销毁的实验 key。生产 primary key 应离线或进入受控硬件,日常系统只持有所需 subkey;过期时间、恢复副本、硬件 PIN 和 designated revoker 由团队制度决定。脚本解析 GPG 时必须使用 --with-colons 和 --status-fd 等机器接口,不能解析会随语言和版本改变的人类输出,GnuPG 在 Programmatic use 中明确给出了这条边界。
公钥可以公开分发,私钥不能。接收方导入公钥后,先通过另一条受认证通道核对完整 fingerprint,再建立组织认可的有效性或信任策略:
gpg --homedir "$OWNER_HOME" --armor --export "$PRIMARY_FPR" \
> "$LAB_ROOT/owner-public.asc"
gpg --homedir "$CALLER_HOME" --batch --import "$LAB_ROOT/owner-public.asc"
IMPORTED_FPR="$(gpg --homedir "$CALLER_HOME" --with-colons --fingerprint \
"$PRIMARY_FPR" | awk -F: '$1=="fpr" {print $10; exit}')"
test "$IMPORTED_FPR" = "$PRIMARY_FPR"
SYNTHETIC_VALUE="$(head -c 24 /dev/urandom | base64 | tr -d '\n')"
printf 'secret=%s\n' "$SYNTHETIC_VALUE" > "$LAB_ROOT/plain.env"
# 这里的 always 只用于已核对 fingerprint 的隔离实验,不是团队信任策略。
gpg --homedir "$CALLER_HOME" --batch --yes --trust-model always \
--output "$LAB_ROOT/plain.env.gpg" --encrypt --recipient "$PRIMARY_FPR" \
"$LAB_ROOT/plain.env"
gpg --homedir "$OWNER_HOME" --batch --yes \
--output "$LAB_ROOT/plain.decrypted.env" --decrypt "$LAB_ROOT/plain.env.gpg"
cmp "$LAB_ROOT/plain.env" "$LAB_ROOT/plain.decrypted.env"
echo 'GPG_ROUND_TRIP_OK'--trust-model always 会跳过常规 key validity 判断,不能复制到生产脚本。团队可以使用受控 key directory、WKD、内部签名流程、TOFU 或 Web of Trust,但必须明确哪个系统证明 fingerprint 与主体身份的绑定。ownertrust 存在于本地 trust database,不会随着普通公钥导出自动传播。
错误 keyring 的反向实验应得到 NO_SECKEY 或等价的“没有私钥”证据:
set +e
gpg --homedir "$OUTSIDER_HOME" --batch --status-fd 2 \
--output "$LAB_ROOT/gpg-should-not-exist" --decrypt "$LAB_ROOT/plain.env.gpg" \
2> "$LAB_ROOT/gpg-deny.status"
rc=$?
set -e
test "$rc" -ne 0
test ! -s "$LAB_ROOT/gpg-should-not-exist"
grep -E 'NO_SECKEY|decryption failed' "$LAB_ROOT/gpg-deny.status"
echo 'GPG_DENY_OK'轮换 subkey 时,新增加密子钥并发布更新后的公钥,只会影响之后选择哪个 subkey 加密。旧密文仍依赖旧私有 subkey;删除旧私有 subkey 会让历史数据不可恢复。revkey 产生并传播撤销自签名,告知更新过公钥的客户端不要再使用该 subkey,却不能远程删除别人已经复制的私钥。primary key 的撤销证书也必须传播后才有判断作用。GnuPG 的 OpenPGP Key Management 对 revkey、过期、ownertrust 和 designated revoker 的边界有完整定义。
SOPS:data key、metadata 与 MAC 怎样连成证据
SOPS 对结构化文件的处理不是“整文件再套一层压缩包”。每个文件使用一个 data key;每个叶子值使用独立初始化向量和认证数据加密。key name 默认保持明文,并参与附加认证数据,使 Git 仍能审查结构,也使攻击者不能把一个密文值无痕搬到另一个 key 下。文件名、key name、recipient、KMS 标识和未加密字段仍会泄露业务拓扑,命名时不能放客户名、账号、内部域名或事件编号。
SOPS 还会对值集合计算 MAC,默认包括未加密值;MAC 本身由 data key 加密并存入 metadata。删值、加值或篡改受保护结构后,即使某个独立密文块看起来合法,完整性校验也应失败。mac_only_encrypted: true 会把未加密值排除在 MAC 之外,只有在确实接受这部分配置可被独立修改时才应启用,不能把它当作“减少 diff”的无害优化。对象与字段定义见 SOPS Reference。
一个 recipient 对应的 metadata entry 保存的是加密后的 data key,不是 age identity 或 GPG 私钥。增加多个普通 recipient 时,每个可用 identity 都能独立恢复 data key。需要多人或多系统共同参与时,应使用 key group 与 Shamir threshold,而不是把三个 recipient 写在同一普通列表里后误以为形成了“二取三”。
用 age recipient 跑通 SOPS 的正向与篡改实验
下面在同一临时目录生成身份、creation rule 与随机配置。.sops.yaml 必须是这个文件名;SOPS 从工作目录向父目录查找,并采用找到的第一份配置。path_regex 匹配的是目标文件路径,规则顺序和执行目录都会改变命中结果。
set -eu
LAB_ROOT="$(mktemp -d)"
trap 'chmod -R u+rwX "$LAB_ROOT" 2>/dev/null || true; rm -rf "$LAB_ROOT"' EXIT INT TERM
umask 077
cd "$LAB_ROOT"
mkdir -p config identities
age-keygen -o identities/team-a.identity
age-keygen -o identities/team-b.identity
age-keygen -o identities/outsider.identity
TEAM_A="$(age-keygen -y identities/team-a.identity)"
TEAM_B="$(age-keygen -y identities/team-b.identity)"
cat > .sops.yaml <<YAML
creation_rules:
- path_regex: ^config/[^/]+[.]sops[.]yaml$
age:
- ${TEAM_A}
encrypted_regex: '^(username|password|token)$'
mac_only_encrypted: false
YAML
SYNTHETIC_VALUE="$(head -c 24 /dev/urandom | base64 | tr -d '\n')"
cat > config/catalog.sops.yaml <<YAML
service: catalog
database:
username: synthetic-user
password: ${SYNTHETIC_VALUE}
YAML
sops --config .sops.yaml encrypt --in-place config/catalog.sops.yaml
grep -q 'ENC\[AES256_GCM' config/catalog.sops.yaml
grep -q '^sops:' config/catalog.sops.yaml
SOPS_AGE_KEY_FILE=identities/team-a.identity \
sops decrypt config/catalog.sops.yaml > config/catalog.decrypted.yaml
grep -q 'synthetic-user' config/catalog.decrypted.yaml
test "$(grep 'password:' config/catalog.decrypted.yaml | awk '{print $2}')" = "$SYNTHETIC_VALUE"
echo 'SOPS_ROUND_TRIP_OK'预期密文中的 service、database、username 等结构仍可审查,匹配 encrypted_regex 的值被替换为 ENC[...],文件尾部出现 sops metadata。把 encrypted_regex 写得过窄,会让新字段以明文进入 Git;把它写成只匹配当前几个 key,又没有内容门禁,是常见的静默泄漏。对高风险配置,更稳妥的基线是默认加密所有值,再仅为确有审查需求的非敏感字段设置明确后缀或规则。
使用 outsider identity 解密应失败:
set +e
SOPS_AGE_KEY_FILE=identities/outsider.identity \
sops decrypt config/catalog.sops.yaml \
> config/should-not-exist 2> config/outsider.stderr
rc=$?
set -e
test "$rc" -ne 0
test ! -s config/should-not-exist
grep -E 'could not decrypt|no identity matched' config/outsider.stderr
echo 'SOPS_RECIPIENT_DENY_OK'再复制密文并改动第一个加密值。下面用 awk 只污染副本中第一个 ENC[AES256_GCM,data:...] 的密文载荷,不碰生产文件:
awk '
!changed && /ENC\[AES256_GCM,data:/ { sub(/data:/, "data:X"); changed=1 }
{ print }
' config/catalog.sops.yaml > config/catalog.tampered.sops.yaml
set +e
SOPS_AGE_KEY_FILE=identities/team-a.identity \
sops decrypt config/catalog.tampered.sops.yaml \
> config/tampered-should-not-exist 2> config/tampered.stderr
rc=$?
set -e
test "$rc" -ne 0
test ! -s config/tampered-should-not-exist
grep -Ei 'MAC mismatch|authentication|could not decrypt' config/tampered.stderr
echo 'SOPS_TAMPER_DENY_OK'不同版本可能在叶子值认证阶段或 MAC 阶段失败,因此自动化应以非零退出码和无明文输出为稳定判据,把错误类别作为辅助证据。不要在生产文件上用 --ignore-mac“先解出来再说”;这会把完整性故障伪装成可用配置。恢复顺序应是保留证据、对比可信 Git 版本、确认 metadata 与 recipient、从受控备份恢复,再重新解密验证。
creation rules、key group 与 threshold 决定团队授权形态
普通 age: 列表是“任意一个 recipient 可解密”。key group 会把 data key 拆成多个份额,shamir_threshold 指定恢复时至少需要多少个组;每个组内部仍是“任一可用 master key 可恢复该组份额”。阈值按组计数,不按 recipient 总数计数。官方示例与语义见 SOPS Key groups。
下面的配置要求开发团队组和恢复组同时可用;每组各放一个临时 age recipient,便于观察失败:
creation_rules:
- path_regex: ^config/critical-[^/]+[.]sops[.]yaml$
shamir_threshold: 2
key_groups:
- age:
- <TEAM_RECIPIENT_FROM_RUNTIME_GENERATION>
- age:
- <RECOVERY_RECIPIENT_FROM_RUNTIME_GENERATION>
mac_only_encrypted: false把两个 identity 分别写入受限文件,再通过同一个 SOPS_AGE_KEY_FILE 指向包含两行 identity 的文件,可以完成恢复。只提供任意一组时应失败,并留下“未达到 threshold”或无法恢复 data key 的证据。真实组织不要把两组 identity 都放进同一个长期 CI secret,否则形式上的二取二被同一个执行主体重新合并成单点。
规则设计还要处理三个现实问题。第一,首个匹配规则决定新文件的 recipient,目录重命名可能改变后续 updatekeys 结果。第二,encrypted_regex、unencrypted_regex、后缀规则只能选一种字段选择策略,混用会造成配置错误。第三,creation rule 只影响新建、updatekeys 或显式操作,不会因为 .sops.yaml 提交了新 recipient 就自动改写所有历史密文。应由 CI 枚举目标文件并执行只读检查,发现 metadata 与规则漂移时阻断合并。
编辑与 Git diff 都可能把明文带出加密边界
sops edit config/catalog.sops.yaml 会解密内容、调用 SOPS_EDITOR 或 EDITOR,编辑器退出后再加密写回。失败现场要区分:identity 无法解包 data key、编辑器退出非零、文件语法无效、MAC 不一致、最终替换文件失败。不要在错误发生后从编辑器恢复目录里复制一份“能打开的明文”回仓库。
编辑器可能创建 swap、backup、local history、崩溃恢复、索引缓存和云同步副本。团队批准的编辑方式应关闭目标目录的备份文件,禁止把 secret 内容送入 AI 补全、遥测和远程索引,并在崩溃后检查临时目录与编辑器恢复区。SOPS 保护保存后的目标文件,不会替编辑器治理所有旁路副本。
Git 默认只能看到密文变化。需要本机审查明文 diff 时,可以为仓库配置 textconv:
printf '*.sops.yaml diff=sopsdiffer\n' >> .gitattributes
git config --local diff.sopsdiffer.textconv 'sops decrypt'
git config --local diff.sopsdiffer.cachetextconv false
# 身份仅在当前受控终端可用时执行;输出可能包含明文。
SOPS_AGE_KEY_FILE="$LAB_ROOT/identities/team-a.identity" \
git diff --textconv -- config/catalog.sops.yaml.gitattributes 可以版本化,包含 identity 路径的 Git config 不应提交。textconv 会把明文交给 Git、pager、终端录屏和调用它的 IDE;不能在共享 CI 日志、代码托管网页或自动评论中启用。更安全的评审方式是由受控审查作业比较允许的结构、schema、recipient 与变更摘要,只把字段名和策略结果返回合并请求,具体值由获批人员在隔离终端核对。
updatekeys、rotate 与底层 secret 轮换不是同义词
接收者变更时先更新 .sops.yaml,再由仍有解密能力的身份执行 sops updatekeys。它根据新规则增删 master key entry,并用新 recipient 重包裹原有 data key。若要更换 data key,使用 sops rotate;官方在 SOPS Key management 中明确区分了两者。
# T0:当前文件只允许 team-a;规则改为 team-a + team-b。
SOPS_AGE_KEY_FILE=identities/team-a.identity \
sops updatekeys -y config/catalog.sops.yaml
# 验证新增的 team-b 能读取,再从规则中删除 team-a。
SOPS_AGE_KEY_FILE=identities/team-b.identity \
sops decrypt config/catalog.sops.yaml > /dev/null
# 仍由当前可用身份解开旧 data key,再只为 team-b 重包裹。
SOPS_AGE_KEY_FILE=identities/team-a.identity \
sops updatekeys -y config/catalog.sops.yaml
# team-b 成功,team-a 对当前版本失败。
SOPS_AGE_KEY_FILE=identities/team-b.identity \
sops decrypt config/catalog.sops.yaml > /dev/null
set +e
SOPS_AGE_KEY_FILE=identities/team-a.identity \
sops decrypt config/catalog.sops.yaml \
> config/retired-should-not-exist 2> config/retired.stderr
rc=$?
set -e
test "$rc" -ne 0
test ! -s config/retired-should-not-exist这条命令链只有在两次修改 .sops.yaml 与命令执行顺序完全对应时才成立。第二次 updatekeys 仍可由 team-a 执行,是因为执行前的密文 metadata 还保留 team-a 对旧 data key 的包裹副本;命令成功写回后,这条入口才从当前文件消失。每次变更后都要做一个新 recipient 正向读取和一个旧 recipient 反向拒绝,不能只检查 metadata 文本。若旧 recipient 已泄漏或成员已经离职,按官方处置顺序在删除 recipient 并完成 updatekeys 后立即轮换 data key:
SOPS_AGE_KEY_FILE=identities/team-b.identity \
sops rotate --in-place config/catalog.sops.yaml
SOPS_AGE_KEY_FILE=identities/team-b.identity \
sops decrypt config/catalog.sops.yaml > /dev/nullrotate 会生成新 data key 并重新加密文件值,所以正常结果不是小幅 metadata diff,而是大量密文变化;评审应核对结构、recipient 和轮换工单,不应把“大 diff”误判为异常后改回旧密文。即使 rotate 完成,旧 Git 提交仍包含旧 data key 的包裹副本;拥有旧 identity 的人仍可解密旧提交。要消除旧明文的业务价值,必须轮换配置里真正的数据库密码、token 或证书,并验证消费者切换、旧值撤销和缓存清理。
泄漏处置的顺序取决于“旧业务凭据是否还能立即撤销”。能立即撤销时,先在目标系统禁用已泄漏值以止血;但不要把替代凭据写进仍允许泄漏 recipient 解密的 SOPS 文件。先从 .sops.yaml 删除该 recipient,执行 updatekeys,再执行 rotate --in-place,确认旧 identity 无法读取当前文件后,才写入或发布新的业务凭据。不能立即撤销时,也要先封住接收者和 data key,再尽快轮换业务值。Git 历史和 artifact 清理随后进行;只重写密文或历史而不撤销底层凭据,仍会留下可使用的泄漏值。
CI 接入要让身份短时出现,让明文不进日志与 artifact
CI 需要同时固定 SOPS 版本、验证二进制来源、取得解密身份、限制仓库和环境权限、执行解密、把明文传给目标进程并清理。云环境优先让工作负载通过 OIDC 换取短期 KMS 权限,避免把长期 age identity 或 GPG 私钥复制到每个 runner。必须使用文件身份时,把它写入 runner 的受限临时目录,不启用 shell trace,不上传工作目录,不跨 job 复用。
set -eu
set +x
umask 077
RUNTIME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/sops-runtime.XXXXXX")"
cleanup() { chmod -R u+rwX "$RUNTIME_DIR" 2>/dev/null || true; rm -rf "$RUNTIME_DIR"; }
trap cleanup EXIT INT TERM
# CI 平台在运行时提供内容;变量名是契约,值不能出现在 YAML、日志或 artifact。
printf '%s' "$PLATFORM_PROVIDED_AGE_IDENTITY" > "$RUNTIME_DIR/age.identity"
unset PLATFORM_PROVIDED_AGE_IDENTITY
export SOPS_AGE_KEY_FILE="$RUNTIME_DIR/age.identity"
sops decrypt config/catalog.sops.yaml > "$RUNTIME_DIR/catalog.yaml"
test -s "$RUNTIME_DIR/catalog.yaml"
./scripts/validate-config --redact "$RUNTIME_DIR/catalog.yaml"
./scripts/start-with-config "$RUNTIME_DIR/catalog.yaml"环境变量仍会进入 runner 进程环境,平台掩码也不能阻止恶意脚本读取;更好的入口是平台 secret file、工作负载身份或直接由密钥服务解包。流水线日志只记录文件摘要、recipient policy 版本、调用主体、运行编号和成功/拒绝,不记录明文、sops -d 输出或完整环境。PR 作业不得继承生产解密身份,来自 fork 的代码尤其不能接触 secret。
回滚时回滚密文提交不等于恢复可用性:旧提交可能指向已经撤销的 recipient 或已经轮换的数据库密码。发布记录应绑定密文 commit、SOPS policy 版本、运行时 secret version 与消费者版本;回退前先验证对应身份和底层凭据仍可用,否则应重新加密当前有效值,而不是盲目恢复旧文件。
Compose 与 Kubernetes 接入必须标出明文落点
Compose 可以把宿主机文件作为 secret source,并只授予指定服务读取。Compose secret 改善容器内挂载路径和服务授权,但不会自动加密宿主机 source file。解密文件仍然是明文,必须放进受限临时目录并在容器停止后删除。Docker 的 Compose secrets reference 说明了 file 与 environment 来源以及服务侧显式授权。
services:
api:
image: example.invalid/catalog-api@sha256:<APPROVED_IMAGE_DIGEST>
secrets:
- catalog_config
command: ["./catalog-api", "--config", "/run/secrets/catalog_config"]
secrets:
catalog_config:
file: ${RUNTIME_SECRET_FILE:?runtime secret file is required}set -eu
umask 077
RUNTIME_DIR="$(mktemp -d)"
trap 'docker compose down --remove-orphans >/dev/null 2>&1 || true; rm -rf "$RUNTIME_DIR"' EXIT INT TERM
export RUNTIME_SECRET_FILE="$RUNTIME_DIR/catalog.yaml"
sops decrypt config/catalog.sops.yaml > "$RUNTIME_SECRET_FILE"
docker compose up -d
docker compose exec -T api ./catalog-api --check-config /run/secrets/catalog_config
docker compose down --remove-orphans检查容器日志、docker inspect、崩溃转储和镜像层,确认没有明文。把 secret 写进 Dockerfile ARG、RUN echo 或构建上下文会进入 layer/cache,运行后删除也无法抹掉历史层。
Kubernetes 可以从标准输入接收解密后的 Secret manifest,避免本地持久文件:
set -eu
set +x
SOPS_AGE_KEY_FILE="$RUNTIME_IDENTITY_FILE" \
sops decrypt config/catalog-secret.sops.yaml |
kubectl apply --server-side --field-manager=secret-delivery -f -
kubectl auth can-i get secret/catalog-config --namespace catalog
kubectl get secret/catalog-config --namespace catalog \
-o jsonpath='{.metadata.resourceVersion}{"\n"}'管道仍会让明文经过 SOPS 与 kubectl 进程内存,并把 Secret 交给 API server。Kubernetes Secret 的 base64 不是加密;还要配置最小 RBAC、审计脱敏、etcd/API 数据静态加密、节点与 Pod 访问边界。控制面静态加密的机制与重写要求见 Kubernetes Encrypting Confidential Data at Rest。不要用 kubectl get secret -o yaml 作为验证,它会把可还原内容送进终端、日志和录屏。
GitOps controller 形态把解密身份放到集群内 reconcile 边界,减少 CI 明文,却把 controller service account、插件二进制、namespace 隔离和 controller 日志变成新的高价值边界。它不是“没有明文”,而是把明文出现的位置从 runner 移到了 controller 与 API server。
明文残留要按进程、文件系统和协作工具逐层排查
事故响应不能只搜索 git grep password。需要沿着实际数据流检查:shell history 是否保存了带值参数,PowerShell transcript 和终端录屏是否开启,编辑器 local history 是否同步,临时目录是否位于普通磁盘,CI workspace 与 artifact 是否复用,容器日志和 APM attribute 是否抓取配置对象,崩溃转储与支持包是否包含进程环境,AI 对话和工单是否粘贴了解密内容。
SOPS 的 exec-env 可以把解密键值放进子进程环境,exec-file 在类 Unix 平台默认可用 FIFO,Windows 或 --no-fifo 会使用传统临时文件并在命令结束后清理。环境变量会被子进程、诊断工具和 crash dump 读取;临时文件会被杀进程、备份软件和文件恢复影响。选择注入方式时要根据目标程序能力和主机威胁模型,而不是把“内存中”简单等同于“不会泄漏”。相关行为见 SOPS Advanced usage。
清理脚本只能删除当前掌握的副本,不能证明 SSD 上的数据已物理擦除,也不能远程清除离职设备。高敏感场景应使用全盘加密、短生命周期工作区、不可持久 runner、硬件隔离和底层凭据短有效期,降低残留副本的可用时间。日志和 artifact 设置保留期,访问导出需要审计;备份恢复演练必须证明恢复出来的是密文和密钥材料分离的状态。
从失败证据反推故障落在哪一层
“SOPS 解不开”至少分成六类。找不到 .sops.yaml 或没有 creation rule 属于配置发现;没有匹配 recipient 属于身份授权;KMS、插件或 GPG agent 超时属于外部解包;MAC mismatch 或认证标签失败属于密文完整性;YAML/JSON 解析错误属于存储格式;输出文件不可写或编辑器退出失败属于本地执行。先保留 stderr、退出码、目标文件摘要、CLI 版本和 identity 类型,再决定是否重试。
不要把 identity 内容、完整 metadata、KMS ARN、GPG keyring 或解密后的文件附到工单。可记录的证据包括:目标文件相对路径、密文 SHA-256、命中的 rule ID、recipient 类型和数量、调用主体、失败阶段、错误分类、运行环境与关联 ID。recipient 字符串本身虽是公开材料,也可能暴露人员与环境关联,公开工单仍应最小化。
常见判断路径如下:
加密阶段没有产生 ENC[...] -> 先查 path_regex 与字段选择规则
当前 identity 无匹配 entry -> 查 metadata recipient 与身份来源,不要反复重试
有匹配 entry 但外部解包失败 -> 查插件/KMS/GPG agent、网络、权限和设备确认
data key 已解开但 MAC 失败 -> 按完整性事故处理,禁止 ignore-mac 常态化
CLI 成功但应用启动失败 -> 查输出格式、文件权限、换行、编码与消费契约
轮换后部分实例仍认证成功 -> 查旧值缓存、滚动进度、旁路配置与撤销状态如果误把明文提交进 Git,应立即撤销底层 secret,并保存必要审计证据;随后再决定历史重写、代码托管缓存处理、镜像与 artifact 清理。先重写 Git、后撤销凭据,会延长攻击窗口。历史重写也无法收回已经 clone、fork、备份或粘贴出去的副本。
选型与迁移要看撤销模型,不只看算法名称
age 适合新建、明确 recipient 的文件加密链,密钥小、行为显式,SOPS 官方也优先推荐在可行时使用 age。GPG 适合已有 OpenPGP 互操作、企业 key directory、签名与硬件 key 管理体系的组织,但 user ID、trust、subkey、agent 和 keyserver 带来更高治理复杂度。云 KMS 或硬件-backed plugin 适合集中身份、审计和设备边界;它们增加网络依赖、权限配置、服务可用性与费用。
SOPS 适合“配置需要和代码一起版本化,但值不能明文进入 Git”的场景。它不提供动态 secret lease,不会自动撤销已经发出的数据库密码,也不是团队密码库。运行时需要短期、可集中撤销的凭据时,应让 Vault、云 secret manager 或工作负载身份承担权威来源,SOPS 只保存非动态引导配置,甚至完全不保存业务 secret。
从 GPG 迁移到 age 时,不能只把 .sops.yaml 的 pgp 改成 age。先生成并备份新的 age identity,加入新 recipient,执行 updatekeys,分别验证 GPG 与 age 可解密;再让 CI、开发机和恢复环境切到 age;完成观察窗口后删除 GPG recipient,并做旧 GPG key 的拒绝验证。移除 GPG recipient 后应执行 rotate,避免离职者或已泄漏私钥继续利用曾获得的旧 data key 解读当前数据;最后决定旧提交如何处置,并轮换真正的业务凭据。旧 GPG key 对历史提交的读取能力不会因当前文件轮换而消失,这一风险只能通过业务凭据失效、历史副本治理和保留策略收敛。
从本地 identity 迁移到 KMS 或硬件插件同理。必须验证无网络、插件损坏、KMS 拒绝、身份过期和灾难恢复路径;保留离线恢复 recipient 时,要防止它长期在线化。迁移成功的证据不是“当前分支能解”,而是所有读写入口、恢复副本、CI runner、发布控制器和离职流程都指向新的权威身份。
备份、成本、供应链与长期治理形成最后一道闭环
加密文件可以跟随 Git 和常规备份,但 identity、GPG 私钥、KMS 权限与恢复材料必须分离备份。只备份密文会在密钥丢失后永久不可恢复;把密文和所有解密身份放进同一备份,又会让备份系统成为单点泄漏。恢复演练应在隔离环境证明:能从批准副本恢复 identity,能核对 fingerprint 或 recipient,能解密指定密文,能发现篡改,演练结束后能销毁临时材料。
供应链门禁至少覆盖 CLI、容器、编辑器插件、age plugin、Git diff helper 和 CI action。固定版本与摘要,验证官方签名或 Sigstore/Sigsum 证明,保存 SBOM 与来源,升级时运行正向解密、错误 identity、MAC 篡改、updatekeys 和 rotate 回归。不要把第三方 action 的浮动 tag 当成可信安装器,也不要让插件目录对普通用户可写。
成本不只有许可证。KMS 会产生调用和密钥管理费用,硬件令牌有采购与遗失恢复成本,GPG 信任体系需要人员维护,SOPS 文件数量会增加轮换与批量重加密时间,CI 解密会占用 runner 并扩大审计范围。真正的优化目标是减少长期 identity、副本和人工例外,而不是追求最低单次加密耗时。
团队运行时需要持续回答这些问题:每个密文文件的 owner 是谁,creation rule 由谁批准,哪些主体能解密当前与历史版本,recipient 变更由谁复核,底层 secret 多久轮换,CI 与 controller 在哪里短暂产生明文,备份由谁恢复,工具升级如何回归,成员离职后如何证明当前版本拒绝且业务凭据已经失效。答案应来自仓库策略、身份系统、发布记录和定期演练,而不是某位维护者的个人 keyring。
当一次变更能够同时给出密文摘要、rule 与 recipient 变化、正向和反向解密证据、data key 是否轮换、底层 secret 是否撤销、运行时明文落点和历史副本处置结论时,加密配置才从“看起来安全的文件”变成可审查、可恢复、可退出的工程系统。
