SOPS + age 操作手册(Windows PowerShell)
适用范围:单人管理,加密 YAML 中指定的敏感字段,加密后的文件可入 Git。
一、安装
scoop install sops age
uv tool install pre-commit二、生成密钥对
# Windows
mkdir -Force $env:APPDATA\sops\age
age-keygen -o $env:APPDATA\sops\age\keys.txt
# Linux / macOS
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt输出类似:
# created: 2024-01-01T12:00:00+08:00
# public key: age1abc123def456...
AGE-SECRET-KEY-1...
- 私钥文件:
$env:APPDATA\sops\age\keys.txt(Windows)/~/.config/sops/age/keys.txt(Linux) - 公钥:
age1abc123def456...(# public key:那一行显示的)
⚠
age-keygen不加-o只输出到屏幕,不会存文件,必须用-o或>重定向。⚠
keys.txt(复数)是sops默认查找的路径,用这个名字可以省去后续配置。ℹ 公钥可以安全公开,它用于加密(别人用你的公钥加密后只有你能解密),后续配置
.sops.yaml中填的就是它。
三、私钥存入 Bitwarden
类型: Secure Note
标题: SOPS age 私钥
内容: 将 keys.txt 全文粘贴(含 # 注释行和 AGE-SECRET-KEY-... 行)
存放位置: Bitwarden 个人保险库即可,不放到组织集合(仅你一人使用)
备注: 可注明两个平台的路径 — Windows: %APPDATA%\sops\age\keys.txt,Linux: ~/.config/sops/age/keys.txt
私钥不存别处,不传文件,不贴聊天。换电脑时从 Bitwarden 取出写回原路径。
四、项目配置 .sops.yaml
在项目根目录创建 .sops.yaml,避免每次加密都手写参数:
creation_rules:
- path_regex: .*\.ya?ml$
age: age1abc123def456...
encrypted_regex: ^(password|secret|key|token|user|admin_user|.*_enc)(_old)?(\.bak)?$| 字段 | 说明 |
|---|---|
path_regex | 匹配需要加密的文件路径。.*\.ya?ml$ 匹配所有 YAML;排除 dotfile(.sops.yaml 等)由 pre-commit 的 files 负责,Go 正则不支持 (?!…) 语法 |
age | 你的公钥,即 --age 参数的值。加密时用此公钥加密数据密钥,解密时用对应私钥 |
encrypted_regex | 需要加密的字段名正则。内置关键词 password|secret|key|token 加 .*_enc 命名约定覆盖不便匹配的字段 |
ℹ
.sops.yaml只含公钥和加密规则,可以安全提交到 Git。换电脑时 clone 仓库即可获得,无需额外恢复。⚠
.sops.yaml是项目级默认配置,不等同于加密文件中的sops:元数据块。元数据块(含加密后的 DEK、MAC 完整性校验值、recipient 信息等)是 SOPS 解密时的必需信息,不能删除。pre-commit hook 正是通过检查文件中是否存在sops:元数据块来判断文件是否已加密。
五、仓库初始化
5.1 初始化 Git 仓库
git init5.2 .gitignore
# .sops.yaml 应提交(仅含公钥),不加入 .gitignore
# pre-commit
/.pre-commit-cache/5.3 配置 pre-commit hook(自动加密)
创建 .pre-commit-config.yaml:
创建 .pre-commit/sops_encrypt.py:
import subprocess, sys
for path in sys.argv[1:]:
try:
with open(path, "rb") as f:
if b"ENC[AES256_GCM" in f.read():
print(f"sops: {path} already encrypted, skipping")
continue
except OSError:
pass
subprocess.run(["sops", "encrypt", "-i", path], check=True)创建 .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: sops-encrypt
name: sops-encrypt
entry: python .pre-commit/sops_encrypt.py
language: system
files: ^(?!\.).*\.(yaml|yml)$
pass_filenames: true
verbose: true安装 hook:
pre-commit install工作原理: 每次 git commit 时,hook 对暂存区中每个没加密过的 YAML 文件执行 sops encrypt -i:
- 文件匹配
.sops.yaml的path_regex→ 自动原地加密,pre-commit 框架会将修改重新暂存 - 已加密文件再次执行 → 跳过
- 文件不匹配任何规则 → SOPS 报错退出,阻止提交(说明
.sops.yaml配置需要调整)
手动触发加密所有文件(不提交):
pre-commit run sops-encrypt --all-files六、准备 YAML 文件
windows_server_01:
host: 192.168.1.100
port: 3389
username: Administrator
password: P@ssw0rd
domain: CORP
bastion: jumpserver.internal七、加密
7.1 两种加密标记方式
SOPS 不支持 YAML tag(如 !sops、!encrypt),因为它操作的是格式无关的通用文档树。但提供以下两种标记方式:
方式一:键名匹配(上文 .sops.yaml 已在用)
通过正则或后缀匹配键名,自动决定是否加密。适合场景:键名固定且有规律,如所有 password、token 字段。
方式二:注释标记
通过注释精确控制每个字段。适合场景:键名不规律,或需要逐字段精细控制。
windows_server_01:
host: 192.168.1.100
port: 3389
username: Administrator
# sops:enc
password: P@ssw0rd
domain: CORP
# 也可以写在同行末尾
bastion: jumpserver.internal # sops:enc# sops:enc写在字段上一行 → 注释归属该字段,命中则加密# sops:enc写在字段同行末尾 → 同上,两者等价- 没有
# sops:enc注释的字段保持明文
对应 .sops.yaml:
creation_rules:
- path_regex: .*\.ya?ml$
age: age1abc123def456...
encrypted_comment_regex: ^sops:enc⚠ 六种标记方式互斥,同一文件只能选一种。需要”大部分 regex + 少数例外”的场景,用
_enc命名约定替代注释标记:将不方便匹配正则的字段命名为xxx_enc,由.*_enc统一覆盖,无需混用。
7.2 首次加密
sops encrypt --encrypted-regex "^(password|secret|key|token|.*_enc)(_old)?(\.bak)?$" --age age1abc123def456... -i servers.yaml⚠
encrypt是 SOPS 的子命令(不是--encryptflag),首次加密时必须加上,否则 SOPS 会进入交互编辑模式而不执行加密。⚠ YAML 中
#是注释符,含#的值必须加引号:password: "P@ss#123" # 正确 password: P@ss#123 # 错误,只有 P@ss 是值,#123 成了注释SOPS 加密的是 YAML 解析后的值(不是原始文本),但会保留原始引号风格(单引号/双引号/无引号)在 YAML AST 中,解密写回时还原。因此含特殊字符的值始终建议加引号 — 既保证 YAML 解析正确,解密后样式也不变。
如果已配置 .sops.yaml,可简化为:
sops encrypt -i servers.yaml加密后文件:
windows_server_01:
host: 192.168.1.100
port: 3389
username: Administrator
password: ENC[AES256_GCM,data:R7FvzXk=,iv:...,tag:...,type:str]
domain: CORP
bastion: jumpserver.internal
sops:
age:
- recipient: age1abc123def456...
enc: DEADBEEF...
encrypted_regex: ^(password|secret|key|token)(_old)?(\.bak)?$
mac: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
version: 3.7.1
lastmodified: "2024-01-01T..."ℹ 文件末尾的
sops:块称为元数据块,包含:
age.enc:用你的公钥加密后的 DEK(数据密钥),解密时必须mac:文件完整性校验值,防止篡改encrypted_regex:存入后编辑无需再指定⚠ 删除元数据块会导致 SOPS 无法解密 —
ENC[...]值自身不含密钥,密钥只存在元数据中。
7.3 正则匹配规则
encrypted_regex 对 YAML 路径中每一级 key 分别匹配,只要任一级命中即加密。例如 server.password 的 YAML 路径为 ["server", "password"],两级都会被分别匹配,^(password)$ 命中第二级,所以该字段会被加密。
限制: 无法基于上层 key 做条件判断。比如无法实现”只加密 server 下的 password,不加密 client 下的 password”,因为各级是独立匹配的,不感知归属关系。如有此类需求,可考虑将不同规则的字段拆分到不同文件,配合不同 .sops.yaml 配置。
7.4 重复加密安全性
已加密的值(ENC[AES256_GCM,...])再次命中 regex 时,SOPS 会自动跳过,不会二次加密,安全幂等。
7.5 支持的文件格式
SOPS 原生支持以下格式(会解析内部结构,支持字段级加密):
| 格式 | 支持 | 说明 |
|---|---|---|
| YAML (.yaml/.yml) | 完整支持 | 支持字段级加密,保留注释 |
| JSON (.json) | 完整支持 | 支持字段级加密 |
| ENV (.env) | 完整支持 | 支持变量级加密 |
| INI (.ini) | 完整支持 | 支持键值对加密 |
| BINARY | 完整支持 | 整个文件作为二进制加密,不解析内部结构 |
⚠ SOPS 不支持 TOML。对于
.toml或.txt文件,可用 BINARY 模式:sops encrypt --input-type binary -i secrets.txt缺点是无法做字段级加密,整个文件变为一个加密 blob,
git diff完全不可读。
八、日常查看/编辑(解密)
sops servers.yaml自动打开编辑器显示明文,保存时自动加密匹配字段。
建议设置编辑器(Windows 默认记事本体验较差):
# 使用neovim
$env:EDITOR = "nvim"解密逻辑:
- Windows 默认从
$env:APPDATA\sops\age\keys.txt读取私钥 - Linux/macOS 默认从
~/.config/sops/age/keys.txt读取 - 如需指定其他路径,设环境变量:
$env:SOPS_AGE_KEY_FILE = "D:\path\mykey.txt" - 也可以
sops decrypt servers.yaml只看内容不编辑
九、修改/添加加密字段
修改加密规则
重新指定 --encrypted-regex 即可覆盖元信息中的旧值:
sops encrypt --encrypted-regex "^(password|api_key|private_key)$" -i servers.yaml添加新敏感字段
在已加密文件中新增匹配 regex 的字段(如新增了 api_key),重新执行加密即可:
sops encrypt -i servers.yaml十、密钥轮换
当需要更换 key pair(如私钥泄露、定期轮换)时:
- 生成新的 key pair(重复第二步)
- 将新私钥写入
keys.txt(路径同上一步),更新.sops.yaml中的age为新公钥 - 对已有加密文件执行:
sops updatekeys -i servers.yamlℹ
updatekeys只用新公钥重新加密文件中的数据密钥(DEK),数据内容本身不变。原理:SOPS 用随机 DEK 加密数据,用 age 公钥加密 DEK。换密钥时只需用新公钥重新加密 DEK 部分,无需解密再重新加密整个文件。ℹ 旧私钥建议保留一段时间,以防有未迁移的文件;确认全部迁移后再从 Bitwarden 更新。
十一、换电脑恢复流程
- 安装
sops和age(Windows:scoop install sops age,Linux: 系统包管理器) uv tool install pre-commit- 从 Bitwarden 取出
SOPS age 私钥→ 写入对应平台路径(见第二节) git clone项目仓库(.sops.yaml、.pre-commit-config.yaml、.pre-commit/sops_encrypt.py随仓库一起获得)pre-commit install重新注册 hooksops servers.yaml即可解密编辑
十二、替代方案:SSH 密钥 + Bitwarden CLI 无落盘解密
适用范围:已有 ssh-ed25519 密钥对且通过 Bitwarden + bw CLI 管理,不希望私钥文件落盘。
概览对比
| 方案一(age 原生密钥) | 方案二(SSH 密钥 + bw CLI) | |
|---|---|---|
| 私钥存储 | keys.txt 落盘 | Bitwarden → 管道 → 内存,用完即弃 |
| 加密密钥 | age 原生 X25519 | ssh-ed25519(复用已有 SSH key) |
| 换电脑恢复 | 从 Bitwarden 取出写入文件 | 零恢复,git clone + shell 配置即用 |
| 额外依赖 | sops、age | sops、age、bw CLI、jq |
前置条件
- 已有
ssh-ed25519密钥对,私钥不含 passphrase(SOPS 不支持加密的 SSH 私钥,见 getsops/sops#1999) - 私钥全文(
-----BEGIN OPENSSH PRIVATE KEY-----…-----END OPENSSH PRIVATE KEY-----)存入 Bitwarden SSH Key 类型条目(不是 Secure Note) - 安装
bwCLI(scoop install bitwarden-cli/brew install bitwarden-cli)和jq
.sops.yaml 配置
creation_rules:
- path_regex: .*\.ya?ml$
age: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@host
encrypted_regex: ^(password|secret|key|token|user|admin_user|.*_enc)(_old)?(\.bak)?$ℹ SSH 公钥直接写在
age:字段中,SOPS 会识别ssh-ed25519前缀并走 age SSH 后端。
准备 Bitwarden 条目
-
在 Bitwarden 中创建 SSH Key 类型的条目(类型选择 “SSH Key”,不是 Secure Note):
- 名称:如
sops-age,需与下文 wrapper 函数的<ITEM_NAME>一致 - 文件夹:放入一个独立文件夹(如
SSH Keys),名称需与<FOLDER_NAME>一致 - 私钥:粘贴无 passphrase 的 ed25519 私钥全文
- 名称:如
-
确认文件夹名称:下文 wrapper 函数中用
<FOLDER_NAME>指定,函数内部会根据文件夹名自动查找其 ID。 -
确认条目名称:下文 wrapper 函数中用
<ITEM_NAME>指定精确名称。函数在指定文件夹中查找名称完全匹配且类型为 SSH Key(type=5)的条目,零个或多个均报错退出。
Shell wrapper 函数
将对应平台的函数添加到 shell 配置文件中(~/.zshrc / ~/.bashrc / pwsh $PROFILE),手动修改函数顶部的 <FOLDER_NAME> 和 <ITEM_NAME>。
zsh / bash:
sops() {
local bw_folder_name="<FOLDER_NAME>" bw_item_name="<ITEM_NAME>"
local folder_id items count item_id
trap 'unset BW_SESSION SOPS_AGE_SSH_PRIVATE_KEY_CMD 2>/dev/null' RETURN
echo >&2 "Unlocking Bitwarden..."
BW_SESSION=$(bw unlock --raw)
export BW_SESSION
if [ -z "$BW_SESSION" ]; then
echo >&2 "sops: Bitwarden unlock failed"
return 1
fi
bw sync >/dev/null
# 按文件夹名获取 folder ID
folder_id=$(bw list folders | \
jq -r --arg name "$bw_folder_name" '.[] | select(.name == $name) | .id')
if [ -z "$folder_id" ]; then
echo >&2 "sops: Folder '$bw_folder_name' not found"
return 1
fi
items=$(bw list items --folderid "$folder_id" --search "$bw_item_name")
# 按名称 + 类型 (5 = SSH Key) 精确匹配,0 或多条均报错
items=$(echo "$items" | jq --arg name "$bw_item_name" '[.[] | select(.type == 5 and .name == $name)]')
count=$(echo "$items" | jq 'length')
if [ "$count" -eq 0 ]; then
echo >&2 "sops: No SSH Key item named '$bw_item_name' in folder '$bw_folder_name'"
return 1
elif [ "$count" -gt 1 ]; then
echo >&2 "sops: Multiple SSH Key items ($count) match '$bw_item_name'"
echo "$items" | jq -r '.[] | " - \(.name) (\(.id))"' >&2
return 1
fi
item_id=$(echo "$items" | jq -r '.[0].id')
SOPS_AGE_SSH_PRIVATE_KEY_CMD="bw get item $item_id | jq -r '.sshKey.privateKey'" \
command sops "$@"
}PowerShell(添加到 $PROFILE):
function sops {
$bwFolderName = "<FOLDER_NAME>"
$bwItemName = "<ITEM_NAME>"
try {
Write-Host "Unlocking Bitwarden..." -ForegroundColor Yellow
$env:BW_SESSION = bw unlock --raw
if (-not $env:BW_SESSION) {
Write-Error "sops: Bitwarden unlock failed"
return
}
bw sync | Out-Null
# 按文件夹名获取 folder ID
$folder = bw list folders |
ConvertFrom-Json |
Where-Object { $_.name -eq $bwFolderName } |
Select-Object -First 1
if (-not $folder) {
Write-Error "sops: Folder '$bwFolderName' not found"
return
}
$allItems = bw list items --folderid $folder.id --search $bwItemName |
ConvertFrom-Json
# 按名称 + 类型 (5 = SSH Key) 精确匹配,0 或多条均报错
$matches = @($allItems | Where-Object { $_.type -eq 5 -and $_.name -eq $bwItemName })
if ($matches.Count -eq 0) {
Write-Error "sops: No SSH Key item named '$bwItemName' in folder '$bwFolderName'"
return
}
if ($matches.Count -gt 1) {
Write-Error "sops: Multiple SSH Key items ($($matches.Count)) match '$bwItemName'"
$matches | ForEach-Object { Write-Host " - $($_.name) ($($_.id))" -ForegroundColor Yellow }
return
}
$itemId = $matches[0].id
$env:SOPS_AGE_SSH_PRIVATE_KEY_CMD = "powershell -NoProfile -Command `"(bw get item $itemId | ConvertFrom-Json).sshKey.privateKey`""
& (Get-Command sops -CommandType Application).Source @args
} finally {
Remove-Item Env:SOPS_AGE_SSH_PRIVATE_KEY_CMD -ErrorAction SilentlyContinue
if ($env:BW_SESSION) {
$null = bw lock 2>&1
Remove-Item Env:BW_SESSION -ErrorAction SilentlyContinue
}
}
}⚠ wrapper 会拦截系统
sops命令,所有sops encrypt/sops decrypt/sops updatekeys等子命令均自动注入SOPS_AGE_SSH_PRIVATE_KEY_CMD。如需手动指定其他密钥来源,用command sops(bash/zsh)或& (Get-Command sops -CommandType Application).Source(pwsh)调用原始命令。⚠ 依赖
jq(仅 bash/zsh 版本):bw get item返回 JSON,jq -r '.sshKey.privateKey'提取 SSH Key 类型条目中的私钥字段。PowerShell 版本使用内置ConvertFrom-Json,无需jq。
- Windows:
scoop install jq- macOS:
brew install jq- Linux:
apt install jq
迁移已有加密文件
若已有文件使用 age 原生密钥(方案一)加密,需用 updatekeys 切换为新 SSH 公钥:
# Windows — 用旧 age 私钥解密,新 SSH 公钥重包 DEK
$env:SOPS_AGE_KEY_FILE = "$env:APPDATA\sops\age\keys.txt"
sops updatekeys --yes servers.yaml# Linux / macOS — 对应路径
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt sops updatekeys --yes servers.yamlℹ
updatekeys只用新公钥重新加密数据密钥(DEK),加密数据内容本身不变。完成后旧 age 私钥即可废弃。
确认新配置可解密后,删除旧 age 私钥文件:
Remove-Item $env:APPDATA\sops\age\keys.txt # Windowsrm ~/.config/sops/age/keys.txt # Linux / macOS同时可从 Bitwarden 中删除旧的 SOPS age 私钥 条目。
换电脑恢复
-
安装
sops、age、bwCLI、jq -
首次使用需登录 Bitwarden(含邮箱 + 主密码 + 2FA 等,只需一次):
bw loginℹ
bw login会在本地存储加密的 API key(~/.config/Bitwarden CLI/data.json),后续bw unlock只需主密码即可解密 API key 并获取 session。换电脑后必须重新bw login,无法跳过。 -
git clone项目仓库(.sops.yaml已含 SSH 公钥,随仓库获得) -
将上文的 wrapper 函数写入对应 shell 配置文件
-
sops servers.yaml→ 输入 Bitwarden 主密码 → 解密成功
无需额外恢复步骤 — 私钥通过 bw 从 Bitwarden 实时获取,全程不落盘。
工作原理
sops servers.yaml
→ SOPS 读取 .sops.yaml,发现 recipient 是 ssh-ed25519
→ SOPS 执行 SOPS_AGE_SSH_PRIVATE_KEY_CMD 指向的命令
→ wrapper 已将该变量设为:bw get item <ID> | ... (BW_SESSION 由环境变量继承)
→ bw 向 Bitwarden 服务器请求条目,输出 SSH 私钥到 stdout
→ SOPS 从 stdout 读入私钥 → 内存中解密 → 私钥即弃
全程私钥不接触文件系统,仅存在于管道和内存中。BW session token 随函数退出自动销毁,下次 sops 调用需重新输入主密码。
官方文档依据
- SOPS age 身份文档确认
SOPS_AGE_SSH_PRIVATE_KEY_CMD为官方支持的私钥来源:getsops.io/docs/usage/identities/age - SSH 公钥(
ssh-ed25519、ssh-rsa)可直接作为 age recipient 使用 SOPS_AGE_SSH_PRIVATE_KEY_CMD的输出必须是非密码保护的私钥