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 init

5.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.yamlpath_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 已在用)

通过正则或后缀匹配键名,自动决定是否加密。适合场景:键名固定且有规律,如所有 passwordtoken 字段。

方式二:注释标记

通过注释精确控制每个字段。适合场景:键名不规律,或需要逐字段精细控制。

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 的子命令(不是 --encrypt flag),首次加密时必须加上,否则 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(如私钥泄露、定期轮换)时:

  1. 生成新的 key pair(重复第二步)
  2. 将新私钥写入 keys.txt(路径同上一步),更新 .sops.yaml 中的 age 为新公钥
  3. 对已有加密文件执行:
sops updatekeys -i servers.yaml

updatekeys 只用新公钥重新加密文件中的数据密钥(DEK),数据内容本身不变。原理:SOPS 用随机 DEK 加密数据,用 age 公钥加密 DEK。换密钥时只需用新公钥重新加密 DEK 部分,无需解密再重新加密整个文件。

ℹ 旧私钥建议保留一段时间,以防有未迁移的文件;确认全部迁移后再从 Bitwarden 更新。


十一、换电脑恢复流程

  1. 安装 sopsage(Windows: scoop install sops age,Linux: 系统包管理器)
  2. uv tool install pre-commit
  3. 从 Bitwarden 取出 SOPS age 私钥 → 写入对应平台路径(见第二节)
  4. git clone 项目仓库(.sops.yaml.pre-commit-config.yaml.pre-commit/sops_encrypt.py 随仓库一起获得)
  5. pre-commit install 重新注册 hook
  6. sops servers.yaml 即可解密编辑

十二、替代方案:SSH 密钥 + Bitwarden CLI 无落盘解密

适用范围:已有 ssh-ed25519 密钥对且通过 Bitwarden + bw CLI 管理,不希望私钥文件落盘。

概览对比

方案一(age 原生密钥)方案二(SSH 密钥 + bw CLI)
私钥存储keys.txt 落盘Bitwarden → 管道 → 内存,用完即弃
加密密钥age 原生 X25519ssh-ed25519(复用已有 SSH key)
换电脑恢复从 Bitwarden 取出写入文件零恢复,git clone + shell 配置即用
额外依赖sopsagesopsagebw CLI、jq

前置条件

  • 已有 ssh-ed25519 密钥对,私钥不含 passphrase(SOPS 不支持加密的 SSH 私钥,见 getsops/sops#1999
  • 私钥全文(-----BEGIN OPENSSH PRIVATE KEY----------END OPENSSH PRIVATE KEY-----)存入 Bitwarden SSH Key 类型条目(不是 Secure Note)
  • 安装 bw CLI(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 条目

  1. 在 Bitwarden 中创建 SSH Key 类型的条目(类型选择 “SSH Key”,不是 Secure Note):

    • 名称:如 sops-age,需与下文 wrapper 函数的 <ITEM_NAME> 一致
    • 文件夹:放入一个独立文件夹(如 SSH Keys),名称需与 <FOLDER_NAME> 一致
    • 私钥:粘贴无 passphrase 的 ed25519 私钥全文
  2. 确认文件夹名称:下文 wrapper 函数中用 <FOLDER_NAME> 指定,函数内部会根据文件夹名自动查找其 ID。

  3. 确认条目名称:下文 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   # Windows
rm ~/.config/sops/age/keys.txt                # Linux / macOS

同时可从 Bitwarden 中删除旧的 SOPS age 私钥 条目。

换电脑恢复

  1. 安装 sopsagebw CLI、jq

  2. 首次使用需登录 Bitwarden(含邮箱 + 主密码 + 2FA 等,只需一次):

    bw login

    bw login 会在本地存储加密的 API key(~/.config/Bitwarden CLI/data.json),后续 bw unlock 只需主密码即可解密 API key 并获取 session。换电脑后必须重新 bw login,无法跳过。

  3. git clone 项目仓库(.sops.yaml 已含 SSH 公钥,随仓库获得)

  4. 将上文的 wrapper 函数写入对应 shell 配置文件

  5. 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-ed25519ssh-rsa)可直接作为 age recipient 使用
  • SOPS_AGE_SSH_PRIVATE_KEY_CMD 的输出必须是非密码保护的私钥