跳到主要内容
ecdsa.com
← 全部文章

C2PA 内容凭证:清单、签名与局限

我们用 c2pa-python 0.38.1(c2pa-rs 0.91.3)对一张 JPEG 签名,分别在签名者不受信任和受信任的情况下验证,修改了一个字节,再让它经过一条普通的图片处理流水线。文中引用的规范文本来自当前版本 C2PA 2.4。

简短回答:C2PA 清单是关于媒体文件的一份已签名陈述:一个列出若干断言的声明(断言记录做过什么,例如“由相机拍摄”或“用生成式 AI 编辑过”,外加文件字节的哈希),由一个密钥以 COSE 签名,该密钥的 X.509 证书就放在签名中。验证器给出的清单状态为 Well-Formed(格式正确)、Valid(有效:签名和哈希都通过检查)或 Trusted(受信任:签名者的证书链可追溯到信任锚,默认是 C2PA 信任列表)。RFC 3161 时间戳能让清单在证书过期后仍然有效。它做不到的是:证明图片所呈现的内容属实,或者在重新编码文件的工具处理之后保留下来。应把有效的凭证理解为“这个签名者为这些字节担保”,而把没有凭证理解为没有任何信息。

清单里有什么

C2PA 规范(2.4 版,2026年4月)规定清单由以下几部分组成:

  • 断言:每个断言都是一个带标签的 CBOR 或 JSON 盒子(box)。c2pa.actions.v2 列出对该资产做过的操作(自 2.1 版起,“现在必须包含 c2pa.created 或 c2pa.opened 之一”),并带有取自 IPTC 词表的 digitalSourceType,用于区分相机拍摄和生成式 AI。c2pa.hash.data 是硬绑定:对资产字节计算的哈希,不包括存放清单本身的那段字节范围。之后还可以有缩略图、成分(制作该资产时所用的资产)和元数据。
  • 声明:“一种经过数字签名、篡改可察觉的数据结构,引用一组断言”,引用方式是带哈希的 URI,因此修改任何一个断言都会破坏声明。
  • 声明签名:对声明计算的 COSE_Sign1,签名者的证书链放在受保护头部中。“必须恰好出现一个身份凭证实例”,自 2.0 版起,该凭证就是 X.509 证书。
  • 可选的时间戳:由 RFC 3161 时间戳机构针对签名签发。

所有这些内容都序列化为 JUMBF 盒子,存放在文件内部(在 JPEG 中是一个 APP11 段,在 MP4 或 HEIF 中是一个盒子),或者存放在远程,通过 URL 引用。资产被编辑时,编辑工具会添加一个新清单,并把之前的清单记录为成分,于是清单存储就成了一条清单链。

允许的签名算法有 ES256、ES384、ES512、PS256、PS384、PS512 和 Ed25519(§13.2.1);密钥必须与算法匹配,RSA 密钥必须至少为 2048 位。对于直播视频,规范建议使用确定性算法,以便相同的片段仍然可以被缓存。

为 JPEG 签名

实验用 pyca/cryptography 49.0.0 创建了一个测试 CA 和一张 P-256 签名者证书,证书带有 C2PA 验证器会查找的扩展密钥用法(EKU):c2pa-kp-claimSigning(1.3.6.1.4.1.62558.2.1),以及为兼容旧版验证器而加的 id-kp-emailProtection。然后对一张生成的 640×360 JPEG 签名,并加上 DigiCert 时间戳:

lab.py(节选):清单定义与签名(c2pa-python 0.38.1)
manifest = {
    "claim_generator_info": [{"name": "ecdsa.com C2PA lab", "version": "1.0"}],
    "title": "sunrise.jpg",
    "assertions": [
        {"label": "c2pa.actions.v2", "data": {"actions": [
            {"action": "c2pa.created", "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"}
        ]}}
    ],
}
signer = c2pa.Signer.from_info(c2pa.C2paSignerInfo(alg=b"es256", sign_cert=chain_pem, private_key=key_pem, ta_url=b"http://timestamp.digicert.com"))
builder = c2pa.Builder.from_json(manifest)
signed = io.BytesIO()
builder.sign(signer, "image/jpeg", io.BytesIO(original), signed)

证书配置很关键:签名者证书的密钥用法(Key Usage)中包含 digitalSignature,带有 CA:FALSE 和声明签名 EKU;根证书是带 keyCertSign 的 CA。缺少合适 EKU 的证书或自签名的叶子证书,即使签名在数学上正确,也无法通过验证。X.509 证书字段一文解释了这些扩展。

验证:Valid、Trusted、Invalid

同一个脚本把文件读回四次:不做任何信任配置;以测试根证书作为信任锚;修改图像数据中的一个字节;以及像上传处理流水线那样,用 Pillow 12.0.0 解码并重新编码图片之后:

lab.py(节选):带信任锚读取、篡改、重新编码
trusted = c2pa.Context.from_dict({"trust": {"trust_anchors": pem(root).decode()}, "verify": {"verify_trust": True}})
read("signed, test root as trust anchor", signed, trusted)

# Change one byte of the compressed image data (after the JPEG Start of Scan marker).
sos = signed.rfind(b"\xff\xda")
tampered = bytearray(signed)
tampered[sos + 200] ^= 0x01
read("one byte of image data changed", bytes(tampered), trusted)

# What many upload pipelines do: decode and re-encode, dropping metadata.
Image.open(io.BytesIO(signed)).save(out := io.BytesIO(), "JPEG", quality=85)
read("re-encoded by an image pipeline", out.getvalue(), trusted)
输出(python:3.14.8-slim-trixie)
c2pa-python 0.38.1, 0.91.3
JPEG: 13376 bytes unsigned, 52438 bytes with the manifest (+39062)
--- signed, no trust anchors configured: validation_state = Valid
    status codes: timeStamp.validated, claimSignature.insideValidity, claimSignature.validated, assertion.hashedURI.match, assertion.hashedURI.match, assertion.hashedURI.match, assertion.dataHash.match, signingCredential.ocsp.skipped, timeStamp.untrusted, signingCredential.untrusted
    signer: Example Newsroom / Example Newsroom Photo Desk, alg Es256, time 2026-10-22T06:43:11+00:00
    assertions: ['c2pa.actions.v2']
    assertion store: ['c2pa.actions.v2', 'c2pa.hash.data', 'c2pa.thumbnail.claim']
    certificate chain in the signature: 981 bytes DER
--- signed, test root as trust anchor: validation_state = Trusted
    status codes: timeStamp.validated, signingCredential.trusted, claimSignature.insideValidity, claimSignature.validated, assertion.hashedURI.match, assertion.hashedURI.match, assertion.hashedURI.match, assertion.dataHash.match, signingCredential.ocsp.skipped, timeStamp.untrusted
--- one byte of image data changed: validation_state = Invalid
    status codes: timeStamp.validated, signingCredential.trusted, claimSignature.insideValidity, claimSignature.validated, assertion.hashedURI.match, assertion.hashedURI.match, assertion.hashedURI.match, signingCredential.ocsp.skipped, timeStamp.untrusted, assertion.dataHash.mismatch
--- re-encoded by an image pipeline: no C2PA manifest found

如何解读:

  • Valid 但不受信任。所有密码学检查都通过了(claimSignature.validated;三个断言对应三条 assertion.hashedURI.match;图像字节对应 assertion.dataHash.match),但结果中有 signingCredential.untrusted:任何人都可以创建 CA 并签名。规范对这一状态有精确定义:清单格式正确、未被修改、签名“在有效期内”验证通过且凭证未被吊销时,为 Valid;只有在此基础上签名凭证还“获得 signingCredential.trusted 成功代码”时,才是 Trusted(§14.3)。
  • Trusted:验证器认识根证书之后。在生产环境中,这个根证书来自 C2PA 信任列表,即“由 C2PA 管理的 X.509 证书信任锚列表,这些信任锚为硬件和软件签名者颁发证书”,验证器必须针对 c2pa-kp-claimSigning EKU 纳入该列表;用户也可以添加自己的信任锚。
  • Invalid:修改一个字节之后。签名依然没有问题,但硬绑定不再成立(assertion.dataHash.mismatch)。裁剪、滤镜或重新压缩都会产生同样的效果,因此编辑工具必须写入新的清单,而不是保留旧清单。
  • 消失:重新编码之后。清单存放在元数据中;解码像素并写出新文件的流水线会把它丢掉,就像丢掉 EXIF 一样。输出中没有任何迹象表明这里曾经有过凭证。

清单让一张 13 KB 的图片增加了 39 KB:除了声明和我们 981 字节的证书链之外,其中还有 c2pa-rs 生成的缩略图,以及附带 DigiCert 证书的时间戳令牌。出现 timeStamp.untrusted,是因为实验没有配置 C2PA TSA 信任列表:时间戳在密码学上是有效的,只是该时间戳机构不在验证器的列表中。

时间戳与证书有效期

规范说得很直接:“没有时间戳的清单,在签名凭证过期或被吊销时即不再有效”;而当时间戳存在且受信任时,验证器“必须使用经证明的时间,而不是当前时间”来检查证书(§10.3.2.5.1、§15.8.2)。这与 Authenticode 的构造相同:证书只需在签名时刻有效,而时间戳机构证明了签名发生在何时。离线签名的设备需要另一种方案;例如,Google 的 Pixel 相机使用“设备端可信时间戳”,让照片“在证书过期后仍然可信,即使拍摄时你的设备处于离线状态”(Google 安全博客)。

信任从何而来

签名者的身份就是证书主体,因此凭证的价值取决于该证书由谁颁发、颁发者核验了什么。C2PA 运营着一项合规计划和这些信任列表;验证器按 EKU 配置信任列表,并且可以添加带有 notBefore 和 notAfter 日期的信任锚。硬件同样重要:Google 称,Pixel 10 的相机应用“达到了 Assurance Level 2,这是 C2PA 合规计划目前定义的最高安全等级”,其声明签名密钥“使用 Titan M2 安全芯片中的 Android StrongBox 生成和存储”,而且只有在 Android 密钥证明(Key Attestation)确认设备和应用是真实的之后才会颁发证书。安全硬件中的密钥详见硬件支持的密钥。

只有已签名的断言才是签名者的陈述。规范举了一个例子:“签名者不对 EXIF 元数据中 GPS 位置的准确性负责,即使该元数据在硬绑定的覆盖范围内”(§9.2.6);只有放在 c2pa.metadata 断言中的元数据才归属于签名者。

内容凭证做不到什么

  • 它们不能证明图片是真实的。相机凭证说明的是这台设备生成了这些字节;摆拍的场景或翻拍屏幕的照片依然是相机拍摄。生成式 AI 凭证只是选择添加它的那个工具所作的陈述。
  • 它们很容易被移除。我们的重新编码没有报任何错误就丢掉了清单,而人们也常常出于隐私或文件大小的考虑故意删除元数据。规范给出的对策是软绑定:一种能在存储库中重新找到清单的指纹或隐形水印。“持久内容凭证”(Durable Content Credential)是指“存在一个或多个软绑定、可借此在清单存储库中发现它的内容凭证”(§2.4.1)。某个平台是否以这种方式存储和查找清单,由平台自己决定。
  • 没有凭证不说明任何问题。网络上的大多数图片都没有凭证;缺少清单并不是被篡改的证据。
  • 它们可能泄露超出预期的信息。成分链、设备名称和位置都会随文件一起传播。当资产成为成分时,创作者可以删除(redact)断言(§6.8);工具应当让人们自己选择包含哪些内容。

如果你在用 C2PA 开发

  1. 在流水线中保留凭证或重新签名。如果你的服务会缩放或压缩上传的文件,要么连同凭证保留原文件,要么创建一个新清单:把原文件作为成分,如实记录 c2pa.edited 或 c2pa.resized 操作,并用你自己的证书签名。
  2. 如实显示验证状态。区分 Trusted 和仅仅是 Valid 的状态,显示证书中的签名者名称;没有清单时,不要显示任何特别的提示。
  3. 使用 C2PA 信任列表和最新软件进行验证。signingCredential.untrusted 或 timeStamp.untrusted 之类的状态码通常意味着配置问题,而不是伪造。
  4. 正确配置证书:使用 c2pa-kp-claimSigning EKU,把密钥放在 HSM 或安全硬件中,并为每个签名加上时间戳。
  5. 不要根据凭证缺失做判断。凭证缺失或被剥离是常态。

参考资料