Component
Other
If Other, please specify
No response
Version
0.7.x
Platform
Linux
What happened?
Summary
When signing an asset with c2pa::Builder (Python bindings: c2pa-python 0.7.x)
using a certificate chain that was generated with standard tools (cryptography
library or OpenSSL CLI), c2pa::Reader reports:
{
"validation_status": [
{ "code": "claimSignature.mismatch", "explanation": "..." }
]
}
The same pipeline using the upstream test fixtures bundled with c2pa-python
(tests/fixtures/certs/es256_certs.pem + es256_private.key) works correctly and
produces validation_state: "Trusted".
Environment
c2pa-python: 0.7.x (Python bindings for c2pa-rs)
c2pa-rs: latest main / 0.36.x
- Platform: Linux x86-64 (Ubuntu 22.04)
- Python: 3.12
Minimal reproducer
import c2pa
import json
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
from datetime import UTC, datetime, timedelta
from pathlib import Path
C2PA_EKU = x509.ObjectIdentifier("1.3.6.1.5.5.7.3.36")
# --- 1. Generate a fresh EC P-256 CA + leaf chain with OpenSSL-equivalent settings ---
ca_key = ec.generate_private_key(ec.SECP256R1())
leaf_key = ec.generate_private_key(ec.SECP256R1())
now = datetime.now(UTC)
ca_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "Test CA"),
]))
.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test CA")]))
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False)
.add_extension(
x509.KeyUsage(
digital_signature=False, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False, key_cert_sign=True,
crl_sign=True, encipher_only=False, decipher_only=False,
),
critical=True,
)
.sign(ca_key, hashes.SHA256())
)
leaf_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test Signer")]))
.issuer_name(ca_cert.subject)
.public_key(leaf_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(leaf_key.public_key()), critical=False)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()),
critical=False,
)
.add_extension(
x509.KeyUsage(
digital_signature=True, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False, key_cert_sign=False,
crl_sign=False, encipher_only=False, decipher_only=False,
),
critical=True,
)
.add_extension(
x509.ExtendedKeyUsage([C2PA_EKU, ExtendedKeyUsageOID.EMAIL_PROTECTION]),
critical=False,
)
.sign(ca_key, hashes.SHA256())
)
leaf_pem = leaf_cert.public_bytes(serialization.Encoding.PEM)
ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM)
chain_pem = leaf_pem + ca_pem
leaf_key_pkcs8 = leaf_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
# Trust settings that add our CA as a trust anchor
ca_b64 = ca_pem.decode()
trust_settings = json.dumps({
"version": 1,
"trust": {
"trust_anchors": ca_b64,
"trust_config": "1.3.6.1.5.5.7.3.36\n1.3.6.1.5.5.7.3.4\n",
}
})
# --- 2. Build a minimal manifest and sign it ---
c2pa.load_settings(trust_settings)
manifest_def = json.dumps({
"claim_generator": "test/1.0",
"assertions": [{"label": "c2pa.actions", "data": {"actions": [{"action": "c2pa.created"}]}}],
})
def sign_callback(data: bytes) -> bytes:
from cryptography.hazmat.primitives.asymmetric.utils import Prehashed
import hashlib
digest = hashlib.sha256(data).digest()
return leaf_key.sign(digest, ec.ECDSA(Prehashed(hashes.SHA256())))
signer = c2pa.Signer.from_callback(
sign_callback,
c2pa.C2paSigningAlg.Es256,
chain_pem,
"",
)
builder = c2pa.Builder(json.loads(manifest_def))
asset_bytes = b"P6\n1 1\n255\n\x00\x00\x00" # minimal 1x1 PPM
output = builder.sign(signer, "image/ppm", asset_bytes)
# --- 3. Verify ---
reader = c2pa.Reader("image/ppm", output)
result = json.loads(reader.json())
active = result["manifests"][result["active_manifest"]]
print("validation_state:", active.get("validation_state"))
print("failures:", [s["code"] for s in result.get("validation_status", []) if s.get("passed") is False])
Expected output:
validation_state: Trusted
failures: []
Actual output:
validation_state: Invalid
failures: ['claimSignature.mismatch']
Bisection findings
We systematically varied every component to isolate the failure:
| Experiment |
cert tool |
key used for signing |
result |
| Upstream test fixtures |
pre-generated |
upstream key |
Trusted |
Fresh cryptography chain + upstream key |
Python cryptography |
upstream key (signing) |
claimSignature.mismatch |
Fresh cryptography chain + fresh key |
Python cryptography |
fresh EC P-256 |
claimSignature.mismatch |
| OpenSSL CLI chain (ca.pem + leaf.pem) |
openssl |
openssl EC key (PKCS#8) |
claimSignature.mismatch |
Signer.from_info with OpenSSL chain |
openssl |
openssl EC key (PKCS#8) |
claimSignature.mismatch |
verify_after_sign=True on any fresh chain |
any |
any |
fails at builder.sign() |
Conclusion: the bug is triggered by the certificate bytes, not by the signing key
or the signing code path. It is reproducible with both Signer.from_callback and
Signer.from_info, and with both Python-generated and OpenSSL-generated chains.
What we checked manually
-
Raw ECDSA verify: We extracted the COSE_Sign1 protected header and payload
from the signed output, reconstructed the Sig_structure, computed SHA-256 of
it, and verified with the leaf public key using cryptography. It passed.
-
SPKI byte equality: When we wrapped the upstream leaf's public key inside a
fresh cryptography-generated chain, the SPKI DER bytes were byte-for-byte
identical to those in the upstream chain. Still claimSignature.mismatch.
-
Protected header bytes: We confirmed that the protected bytes passed to our
sign_callback match the protected field of the COSE_Sign1 structure in the
final output. The Sig_structure we signed is the same one the verifier checks.
-
Certificate profile check: Both chains pass the certificate_profile check
inside c2pa-rs; claimSignature.mismatch occurs after that, in the ECDSA
validator.
-
verify_after_sign flag: Enabling this causes builder.sign() to return an
error immediately after signing, confirming the verifier's internal sanity check
on the just-signed data also fails.
Hypothesis
Based on reviewing sdk/src/crypto/cose/verifier.rs (commit in the 0.36.x range):
let tbs = sign1.tbs_data(additional_data);
let (_, sign_cert) = X509Certificate::from_der(end_entity_cert_der)?;
let pk_der = sign_cert.public_key().raw;
validator.validate(&sign1.signature, &tbs, pk_der)?;
The tbs_data result fed to the validator when verifying might differ from the
tbs bytes that were fed to the signing callback. A candidate cause is that
c2pa-rs re-serialises (or reorders) part of the CBOR structure between signing and
verification, and this serialisation is sensitive to something in the certificate
that differs between the upstream fixture chain and any freshly-generated chain —
e.g. a non-deterministic CBOR map encoding or a certificate-derived hash used in the
additional data.
Another candidate: the upstream fixture chain has a specific extension combination
(it uses Ed25519 roots) that causes a different code path through the certificate
profile validator, resulting in different additional_data bytes being passed to
tbs_data.
Impact
Any integrator who generates their own EC cert chain (as recommended by the C2PA
spec and the c2pa-rs docs) will hit this bug. The only currently-working path is
to use the pre-generated test fixtures bundled with c2pa-python, which are
MIT-licensed Adobe test material and are unsuitable for production use.
Workaround (our current approach)
We vendor the MIT-licensed upstream fixtures into our dev environment for local
development and demos. Production is blocked pending this fix.
References
c2pa-rs verifier: sdk/src/crypto/cose/verifier.rs
c2pa-rs ECDSA validator: sdk/src/crypto/raw_signature/rust_native/validators/ecdsa_validator.rs
c2pa-rs cert profile: sdk/src/crypto/cose/certificate_profile.rs
c2pa-python upstream fixtures: tests/fixtures/certs/
What did you expect to happen?
validation_state: Trusted
failures: []
Component
Other
If Other, please specify
No response
Version
0.7.x
Platform
Linux
What happened?
Summary
When signing an asset with
c2pa::Builder(Python bindings:c2pa-python0.7.x)using a certificate chain that was generated with standard tools (
cryptographylibrary or OpenSSL CLI),
c2pa::Readerreports:{ "validation_status": [ { "code": "claimSignature.mismatch", "explanation": "..." } ] }The same pipeline using the upstream test fixtures bundled with
c2pa-python(
tests/fixtures/certs/es256_certs.pem+es256_private.key) works correctly andproduces
validation_state: "Trusted".Environment
c2pa-python: 0.7.x (Python bindings forc2pa-rs)c2pa-rs: latest main / 0.36.xMinimal reproducer
Expected output:
Actual output:
Bisection findings
We systematically varied every component to isolate the failure:
cryptographychain + upstream keycryptographyclaimSignature.mismatchcryptographychain + fresh keycryptographyclaimSignature.mismatchopensslopensslEC key (PKCS#8)claimSignature.mismatchSigner.from_infowith OpenSSL chainopensslopensslEC key (PKCS#8)claimSignature.mismatchverify_after_sign=Trueon any fresh chainbuilder.sign()Conclusion: the bug is triggered by the certificate bytes, not by the signing key
or the signing code path. It is reproducible with both
Signer.from_callbackandSigner.from_info, and with both Python-generated and OpenSSL-generated chains.What we checked manually
Raw ECDSA verify: We extracted the COSE_Sign1 protected header and payload
from the signed output, reconstructed the
Sig_structure, computedSHA-256ofit, and verified with the leaf public key using
cryptography. It passed.SPKI byte equality: When we wrapped the upstream leaf's public key inside a
fresh
cryptography-generated chain, the SPKI DER bytes were byte-for-byteidentical to those in the upstream chain. Still
claimSignature.mismatch.Protected header bytes: We confirmed that the
protectedbytes passed to oursign_callbackmatch theprotectedfield of the COSE_Sign1 structure in thefinal output. The Sig_structure we signed is the same one the verifier checks.
Certificate profile check: Both chains pass the
certificate_profilecheckinside
c2pa-rs;claimSignature.mismatchoccurs after that, in the ECDSAvalidator.
verify_after_signflag: Enabling this causesbuilder.sign()to return anerror immediately after signing, confirming the verifier's internal sanity check
on the just-signed data also fails.
Hypothesis
Based on reviewing
sdk/src/crypto/cose/verifier.rs(commit in the0.36.xrange):The
tbs_dataresult fed to the validator when verifying might differ from thetbsbytes that were fed to the signing callback. A candidate cause is thatc2pa-rsre-serialises (or reorders) part of the CBOR structure between signing andverification, and this serialisation is sensitive to something in the certificate
that differs between the upstream fixture chain and any freshly-generated chain —
e.g. a non-deterministic CBOR map encoding or a certificate-derived hash used in the
additional data.
Another candidate: the upstream fixture chain has a specific extension combination
(it uses Ed25519 roots) that causes a different code path through the certificate
profile validator, resulting in different
additional_databytes being passed totbs_data.Impact
Any integrator who generates their own EC cert chain (as recommended by the C2PA
spec and the
c2pa-rsdocs) will hit this bug. The only currently-working path isto use the pre-generated test fixtures bundled with
c2pa-python, which areMIT-licensed Adobe test material and are unsuitable for production use.
Workaround (our current approach)
We vendor the MIT-licensed upstream fixtures into our dev environment for local
development and demos. Production is blocked pending this fix.
References
c2pa-rsverifier:sdk/src/crypto/cose/verifier.rsc2pa-rsECDSA validator:sdk/src/crypto/raw_signature/rust_native/validators/ecdsa_validator.rsc2pa-rscert profile:sdk/src/crypto/cose/certificate_profile.rsc2pa-pythonupstream fixtures:tests/fixtures/certs/What did you expect to happen?
validation_state: Trusted
failures: []