Skip to content

claimSignature.mismatch produced for any freshly-generated EC cert chain via c2pa::Builder, even when manual ECDSA verification of the same signature passes #2150

@drmahdikazempour

Description

@drmahdikazempour

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

  1. 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.

  2. 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.

  3. 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.

  4. Certificate profile check: Both chains pass the certificate_profile check
    inside c2pa-rs; claimSignature.mismatch occurs after that, in the ECDSA
    validator.

  5. 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: []

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions