Notation 与 Notary Project OCI 制品签名信任手册
发布流水线给 registry.example.test/payments:stable 做过签名,部署门禁也能列出一份关联 signature。一次常规回滚后,值班人员发现实际启动的是另一份 manifest:镜像 Tag 在签名与部署之间被重新指向,新 digest 没有合格签名。事故并不是密码学突然失效,而是门禁把“Tag 下能找到签名”误当成“当前 digest 通过了指定身份、证书链和策略的联合验证”。
另一个团队为了让撤销服务故障时仍能发布,把生产仓库策略从 strict 改为 audit。日志里确实出现了证书链和身份告警,但命令仍返回成功,后续任务只检查退出码,于是把观察模式当成强制准入。修复不能只把级别改回去,还要确认策略文件、命名 trust store、插件目录、签名身份和 Registry 中的关联对象都没有在宽松窗口内被替换。
先拆开 Notation、规范、旧系统与 TUF
Notation 是面向 OCI 制品的 CLI 和库实现,Notary Project specifications定义签名格式、签名方案、OCI 存储、trust store、trust policy 与插件契约。Notation 按这些规范对 Registry 中的制品签名进行生产和验证,不需要部署一个同名签名服务端。Registry 负责保存 subject manifest、signature manifest 与 envelope blob,PKI、KMS 或签名服务负责密钥和证书,消费端的策略才负责决定是否信任。
旧 Notary 是另一套 client/server 系统,使用 TUF trusted collection 保护 Docker Content Trust 等消费场景。它的 root、delegation 和服务端数据库不能直接变成 OCI signature manifest,也不是 Notation 的后端。官方已停止使用有歧义的“Notary v2”称呼;迁移文档应分别写清旧 Notary、Notation 或具体 Notary Project specification。TUF 则是独立的更新框架,用 root、targets、snapshot、timestamp、threshold 与客户端持久状态抵御更新仓库攻击;Notation 的 X.509 签名验证没有 TUF role,也没有元数据版本链。
这个区别直接影响选型。需要证明“某个 OCI digest 由哪些证书身份签名,并按哪条仓库策略验证”时,Notation 是合适入口;需要保护完整更新仓库、轮换根角色、约束元数据新鲜度和委派路径时,应使用 TUF。需要多人批准时也不能把 Notation 伪装成阈值系统:它会遍历关联签名,只要至少一份满足策略,整体验证就可成功,并不支持 n-of-m。
安装与启用从独立校验二进制开始
生产基线应分别固定 Notation CLI 发行线和 specifications 版本,因为两者独立演进。稳定接入可以绑定 CLI 1.3.x 与 specifications 1.1.0,升级时再读取 Notation releases 和规范 releases,不能根据相似版本号推导兼容。预览发行线中的对象或参数不得提前成为生产合同。
从官方 release 取得与平台匹配的压缩包和 checksum,在与下载渠道独立的受控记录中核对摘要,然后执行:
notation version
notation plugin list
notation policy show第一条输出进入构建证据;后两条建立当前插件和策略基线。若使用本地密钥,私钥文件只对签名身份可读;若使用 KMS,则通过经过审核的插件注册 key。Registry 凭据由外部 secret 注入,不写入仓库、policy 或 --plugin-config。URL 安装插件时必须给出发布方公布的 SHA-256:
notation plugin install \
--url "https://plugins.example.test/notation-kms.tar.gz" \
--sha256sum "$PLUGIN_SHA256"
notation plugin listchecksum 错误必须在插件首次执行前失败,但“二进制和 checksum 来自同一被攻陷站点”仍无法证明发布者身份。应额外验证发布签名或内部镜像入库记录,并把插件实际文件摘要纳入主机完整性监控。生产环境不用 --insecure-registry;需要明文实验 Registry 时,应限制在一次性隔离网络。
digest、signature manifest 与 envelope 组成签名对象
签名起点是 repository@sha256:<digest>。Tag 可以作为人类入口,但 CLI 最终必须解析为 OCI descriptor,descriptor 至少包含 mediaType、digest 和 size。签名 payload 的 targetArtifact 再次保存该 descriptor,因此验证末端必须比较“调用者准备消费的 digest”和“payload 声明的 digest”。只证明 envelope 的密码学签名有效,还没有证明它签的是当前制品。
按照签名规范,签名 envelope 可以是 JWS JSON 或 COSE。envelope 被放进 blob,外层 OCI image manifest 的 config 标记为 Notary signature,subject 指回被签 manifest。验证器通过 OCI Distribution Referrers API 枚举 subject 的候选 signature manifest,再读取 envelope 和 payload。manifest annotation 中的证书指纹只能帮助过滤候选,不能代替证书链、身份和策略验证。
链路可压缩成下面这组不可省略的状态:
输入 repository@digest
-> 读取 subject OCI descriptor
-> 枚举关联 signature manifest/referrer
-> 解析 JWS 或 COSE envelope
-> 校验 payload 与 targetArtifact descriptor
-> 校验证书链并锚定命名 trust store
-> 选择唯一 registry scope policy
-> 校验 trusted identity、时间、过期与撤销
-> 至少一个候选通过,返回 resolved digest 与结果notation list 只证明 Registry 能发现候选,notation inspect 只帮助查看 envelope、证书和属性。二者即使有输出,notation verify 仍可能因错误 subject、未知 critical attribute、链不可信、身份不匹配或撤销失败而非零退出。门禁必须执行 verify,并保存结构化结果、目标 digest、policy 摘要、trust store 指纹和 CLI 版本。
trust store 和 policy 字段共同决定放行边界
Trust store 是命名证书集合,当前类型包括 ca、signingAuthority 和 tsa。命名只提供引用关系,不自动授予信任;根证书必须被加入正确类型的 store,适用 policy 还必须在 trustStores 中引用它。CA store 保存 root trust anchor;不要把 intermediate 当根固定,否则中间 CA 正常轮换时会意外中断验证。目录和证书 symlink 必须被拒绝,以免路径替换绕过文件完整性。
下面的策略把生产仓库收紧到精确 scope、严格验证、命名 CA 和精确 X.509 subject:
{
"version": "1.0",
"trustPolicies": [
{
"name": "payments-production",
"registryScopes": ["registry.example.test/payments"],
"signatureVerification": {"level": "strict"},
"trustStores": ["ca:release-pki"],
"trustedIdentities": [
"x509.subject:C=CN,ST=Zhejiang,L=Hangzhou,O=Example,OU=Release,CN=payments-signer"
]
}
]
}registryScopes 先做完整 repository URI 的 exact match,再考虑唯一全局 *;短名不会自动补全,无匹配必须失败。多个非全局策略同时覆盖同一仓库会制造歧义,应在导入阶段拒绝。trustedIdentities: ["*"] 表示接受该 trust store 能链到的任意签名身份,并不等于“所有签名都可信”,但它会把一个 CA 下本不相关的团队也纳入信任,生产策略应使用精确 subject RDN。
strict 对完整性、真实性、可信时间、签名过期和撤销全部执行阻断。permissive 仍强制完整性与真实性,却把可信时间、过期和撤销降为日志;它可在这些失败时返回成功。audit 用于迁移观察:存在签名时完整性仍必须通过,但真实性、时间、过期和撤销主要记录告警;无签名也不能被当成强信任结果。skip 不执行签名验证,且不能作为全局 scope。单项 override 同样会改变安全语义,必须有精确 scope、责任人、到期退出条件和告警采集,不能只依赖退出码。
证书链、可信时间与撤销不是一个开关
envelope 应携带从 leaf、intermediate 到 root 的有序完整链。普通 notary.x509 签名可以带 RFC 3161 TSA countersignature;启用可信时间验证时,policy 还要引用独立的 tsa trust store。无可信时间证据时,验证发生的时刻必须落在链中证书有效期内,并结合撤销状态判断。签名自身的 expiry 是 signer 给出的使用期限,不是证书 NotAfter,也不是 Registry 保留期。
notary.x509.signingAuthority 使用受信 Signing Authority 产生的 Authentic Signing Time,必须把 Signing Authority trust store 与普通 CA store 分开。混用会让普通 CA 下的最终实体获得本不属于它的时间授权能力。verifyTimestamp 的 afterCertExpiry 也不是“不配置 TSA 根”,它只改变何时触发 TSA 验证。
撤销通常依赖 OCSP 或 CRL。证书没有声明 endpoint 时,“没有执行撤销查询”不能表述为“证明未撤销”;网络失败、响应过期和未知状态也要区分。离线区若无法访问撤销服务,不能临时把全局 policy 改成 audit。可选择在联网区制作带审计来源的撤销快照、使用受控扩展验证插件,或暂停新的高风险推广,但必须明确新鲜度、同步失败和失效语义。
第一组正反实验验证 digest、identity 与 chain
实验使用隔离 Registry、测试 PKI 和已导入的 exact-scope strict policy。SIGNED_REF 必须是 digest 引用,MOVED_TAG 初始解析到同一 digest。先保存根证书指纹、policy 摘要和版本,再签名、验证;随后让 Tag 指向另一 manifest,并替换为错误 identity policy。
set -euo pipefail
: "${SIGNED_REF:?registry.example.test/lab@sha256:...}"
: "${MOVED_TAG:=registry.example.test/lab:stable}"
: "${UNSIGNED_DIGEST:?set the unsigned manifest sha256 digest without the prefix}"
notation version | tee evidence.notation-version.txt
notation policy show | tee evidence.policy.json
sha256sum root-ca.pem evidence.policy.json > evidence.inputs.sha256
# 正向:对不可变 digest 签名并按 exact policy 验证。
notation sign --key lab-release "$SIGNED_REF"
notation list "$SIGNED_REF" | tee evidence.signature-list.txt
notation inspect "$SIGNED_REF" | tee evidence.signature-inspect.txt
notation verify "$SIGNED_REF" | tee evidence.verify-pass.txt
# 反向 A:把 stable 移到未签名 digest 后,按 Tag 验证必须失败。
oras tag registry.example.test/lab@sha256:"$UNSIGNED_DIGEST" stable
if notation verify "$MOVED_TAG" >evidence.tag-fail.txt 2>&1; then
echo 'ERROR: moved tag was accepted' >&2
exit 1
else
echo 'EXPECTED: moved tag resolved to an untrusted digest'
fi
# 反向 B:导入 subject 不匹配的 strict policy,候选仍可见但验证必须失败。
notation policy import wrong-identity-policy.json --force
notation list "$SIGNED_REF" | tee evidence.still-discoverable.txt
if notation verify "$SIGNED_REF" >evidence.identity-fail.txt 2>&1; then
echo 'ERROR: wrong certificate identity was accepted' >&2
exit 1
else
echo 'EXPECTED: signature exists, identity policy rejected it'
fi成功证据是原始 digest 返回零退出,并能把 payload descriptor、证书链和 policy identity 对齐;反例证据分别是新 digest 无合格签名、旧签名仍可发现但错误 identity 被拒绝。恢复时重新导入受控 policy,把 Tag 指回经批准 digest 或删除测试 Tag,删除测试 signature manifest,并核对生产仓库未被实验凭据触及。
第二组夹具验收揭示 audit 与单签通过语义
第二组为同一 digest 准备三份独立 Registry fixture。A 同时关联不受信 identity 签名和受信 identity 签名;B 只关联前者;C 关联一份 payload 或 primitive signature 已被逐位修改、但 signature manifest 结构仍可发现的签名。删除或重推 signature manifest 的命令依赖 Registry 实现,不能虚构成通用 helper;夹具生成器必须由项目单独交付并记录每个 subject、signature manifest 和 envelope digest。
对 A 导入 strict-policy.json 后执行 notation verify "$REF" 应为零退出,证明“至少一个候选满足策略”即可成功。对 B 执行相同命令必须非零退出,证明剩余不可信签名既不能通过,也不能组成 2-of-2。对 C 导入 audit-policy.json 后执行相同命令仍必须非零退出,证明 audit 不豁免已存在签名的完整性失败。三轮都保存 CLI 版本、policy digest、subject digest、候选 signature manifest digests、标准输出、标准错误和退出码;缺少任一夹具生成记录时,测试状态必须标为 not-run,不能生成“已复现”决定。
这组证据同时排除两个误解:Notation 不是 n-of-m,也不是“audit 什么都放行”。它遍历候选并接受至少一个完整满足当前 policy 的签名;audit 可以把 authenticity、时间、过期或撤销问题降为日志,却不能把已存在签名的位级篡改变成可信。实验后恢复 strict,删除所有测试 manifest、helper 产生的 blob 与测试证书,并再次验证一份干净签名。
插件是宿主可执行程序而不是天然可信根
Notation 插件位于 {NOTATION_LIBEXEC}/plugins/<name>/notation-<name>,由 CLI 作为子进程执行,通过参数和 stdin/stdout/stderr 交换 JSON。它可以接触 payload、签名、证书链、KMS 上下文,某些能力还可生成完整 envelope 或参与 identity、revocation 扩展验证。因此插件目录可写、二进制替换、恶意标准输出、崩溃转储和远端签名凭据泄漏都属于主机级风险。
插件 contract 有独立版本线,宿主通常只接受匹配 major 且不高于自身 minor 的 contract;插件还必须声明所需 capability。“能被 plugin list 看见”不证明它能生成目标格式,也不证明安全。Signature Envelope Generator 对属性和 TSA countersignature 的控制面大于 raw signature generator,但宿主仍应验证 payload descriptor 未被替换、算法可接受、证书链合法。未知 critical attribute 没有受信处理器时必须失败。
生产主机将插件目录设为管理员只写、运行账号只读,拒绝 symlink,记录文件摘要和来源。插件升级先在隔离 Registry 重放 JWS、COSE、KMS、TSA、失败 identity 与撤销样本,再逐池发布。--force 会绕过部分覆盖或降级保护,不能无条件放进自动化。专有 verification plugin 还会降低签名跨工具可移植性,退出设计应保留标准 envelope 和足以用另一验证器复核的证据。
项目与流水线接入要传递 digest 和结构化决定
生产者流水线先构建并推送 OCI manifest,读取 Registry 返回的 digest,再把这个不可变引用交给签名 job。签名 job 只接收 digest、受控 key alias 和枚举化 metadata,不重新打包、不重新解析 Tag。其身份只能写指定 repository 的 referrer,并只调用对应 KMS key。成功后保存 subject digest、signature manifest digest、envelope 格式、证书链指纹、key/plugin 版本和签名结果。
消费者流水线重新解析交付引用并冻结 digest,拉取 policy 与 trust store 的受控快照,执行 notation verify repository@digest。验证 job 不持有部署写权限,只输出类似下面的 decision;部署 job 仅消费已批准 digest,不再次解析 Tag:
artifact_trust_decision:
subject: "registry.example.test/payments@sha256:<digest>"
policy: "payments-production@sha256:<policy-digest>"
trust_store: "ca:release-pki@sha256:<bundle-digest>"
verified_identity: "CN=payments-signer,OU=Release,O=Example,..."
signature_manifest: "sha256:<signature-manifest-digest>"
result: "allow"策略仓库、trust store 分发和插件目录都应使用独立变更审批。流水线在 Registry 暂时不可达、撤销状态 unknown 或策略无匹配时返回“无法判断”并 fail closed;开发环境若需要观察,只能使用独立 scope 和明确告警,不能改全局策略。复制、GC 或镜像提升还要验证 referrers 一起迁移,否则目标 Registry 只有镜像 digest,没有验证所需签名。
排障按发现、解析、密码学、策略和网络分层
notation list 为空时,先确认输入解析到哪个 digest、Registry 是否支持所需 referrers 行为、认证主体能否读取附件,以及复制或 GC 是否丢失 signature manifest。不要先改 policy,因为发现阶段尚未进入身份判断。能 list 但 inspect 失败,通常落在 manifest、layer media type、envelope 编码或 blob 完整性。
inspect 正常而 verify 失败,再按链路读取第一项确定性证据:payload descriptor 是否等于目标 digest;envelope 是 JWS 还是 COSE;证书链是否完整;root 是否位于 policy 引用的命名 store;exact repository scope 是否命中;subject DN 是否精确匹配;系统时间、TSA、OCSP/CRL 状态如何。permissive 或 audit 返回零退出时还要读取 warning,不能把零退出覆盖成“全部检查通过”。
插件错误单独检查宿主和 contract 版本、capabilities、二进制 SHA-256、文件权限、KMS 身份、网络与脱敏后的 stderr。插件超时不应自动切换本地私钥,也不应重试无限次。Registry 429/5xx、OCSP 暂时失败可有限退避;digest mismatch、错误 identity、链不可信和 unknown critical attribute 是确定性拒绝,重试不会让它们变可信。
权限与敏感数据围绕四个可改写面收缩
私钥或 KMS key ID、Registry token、插件 token、TSA 凭据、OCSP/CRL 网络信息、--plugin-config、CI 环境变量和调试日志都是敏感面。证书常用于公开验证,但 subject、组织结构、内部 CA 地址、构建 ID 与 signed/unsigned attributes 仍可能暴露内部拓扑;secret 不得进入 payload 属性或 OCI annotation。
更容易被忽略的是完整性权限。攻击者无需破解密钥,只要能把 policy 改成 audit/skip、把 identity 改成 *、向 named store 加入恶意 root,或替换插件,就可能改变放行结果。因而至少拆分 signer、policy maintainer、trust-store distributor、plugin administrator、Registry evidence writer 和 verifier reader。验证器运行账号只读 policy、store 与插件文件,发布 job 不能修改自己的消费策略。
审计记录保留变更主体、旧新文件 digest、scope、审批、验证样本和退出条件,不打印私钥、token 或完整插件输入。撤销查询和 TSA 请求会向外部服务暴露验证活动及时间侧信道,隔离区、跨境区和高敏仓库应在架构设计时确定代理、缓存或离线机制。
容量、成本与高可用取决于候选数和外部依赖
签名存储成本由制品数、每个 digest 的候选签名数、envelope/证书链大小、跨 Registry 复制、保留期和审计归档共同决定。验证成本不只是一轮公钥运算,还包括 referrers 枚举、manifest/blob 下载、证书链构建、TSA 与撤销网络查询、插件进程启动和 KMS 调用。候选签名无限增长会放大延迟与 DoS 面,应限制单制品候选数、响应体、插件时长和总验证预算,并监控各阶段 p95/p99、失败原因和 warning 数。
成功缓存键至少包含 subject digest、policy digest、trust-store bundle digest、验证器/插件版本和撤销新鲜度;不能只按 Tag 缓存。证书、CRL/OCSP、TSA 和 Registry 任一依赖故障,都可能让严格验证不可用。高可用策略应预先选择暂停推广、切换受控只读镜像、使用仍在新鲜窗口内的撤销材料,或仅重部署已经验证且策略未变的 digest,绝不能自动降到 audit。
多副本 verifier 可以提高吞吐,却要求 policy、trust store 和插件快照一致。签名端的 HA 还受 KMS、Signing Authority 或 TSA 影响;故障时宁可停止产生新签名,也不要临时把私钥复制到 runner。成本评估同时计入 Registry 附件、KMS/TSA/OCSP 请求、插件供应、日志、跨区流量和恢复演练,具体配额与价格从对应服务当前说明读取。
升级、轮换、迁移与退出要保住历史可验证性
升级 CLI 或插件前,冻结一组代表性样本:JWS、COSE、可信与不可信 identity、过期、撤销、TSA、未知 critical attribute、多候选签名和无签名 digest。候选版本用同一 policy/store 重放,比较 resolved digest、身份、warning 与退出码。先升级验证池,再升级 signer;出现问题时回滚二进制和插件快照,不回滚已经发布的 digest 或证书事实。
CA、Signing Authority 或 TSA 根轮换采用双信任:先向所有 verifier 分发新根并验证旧签名仍通过,再切 signer 产生新链,确认新旧消费者都能验证,最后按历史保留需求移除旧根。删除旧 root 会立即让依赖它的历史签名失去可验证性;Registry GC 删除 signature manifest 也有同样后果。轮换完成的证据不是“新证书已签发”,而是新旧样本、离线区和灾备镜像都按预期收敛。
从旧 Notary 迁移时,先盘点 trusted collection、root、delegation、签名对象、Docker Content Trust 客户端和 trust pinning,再为每个 OCI digest 重新生成 Notation 签名与精确 policy。旧数据库不能直接复制成新对象。切换 JWS/COSE 或另一验证器时,检查实际格式支持、critical attribute 和专有插件依赖,不把规范允许误写成所有实现已经互通。
退出时先停止新签名,导出 subject digest、signature manifest、envelope、证书链、policy、trust store、插件/CLI 版本和审计决定;在空环境完成离线复核。随后撤销 KMS 授权、Registry evidence writer、插件凭据和 verifier reader,清理临时 key、缓存、测试 root 与流水线 secret。最终反向证明应包括旧 signer 无法再签、旧插件无法执行、旧策略写者无法改门禁,而归档制品仍能按保留要求验证。
Notation 与 Notary Project 概览。Notation 安装与校验入口。Notation 安全部署与文件权限
