Validate C2PA credentials before attribution

This commit is contained in:
Victor Kuznetsov
2026-08-15 11:31:59 -07:00
parent 8201ada070
commit 2eab24a2e1
18 changed files with 826 additions and 47 deletions
+22 -4
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import struct
import zlib
from pathlib import Path
import cv2
import numpy as np
@@ -10,9 +12,6 @@ import pytest
from PIL import Image
from PIL.PngImagePlugin import PngInfo
if TYPE_CHECKING:
from pathlib import Path
@pytest.fixture
def clean_photo(tmp_path: Path) -> Path:
@@ -76,3 +75,22 @@ def tmp_clean_png(tmp_path: Path) -> Path:
path = tmp_path / "clean.png"
img.save(path, pnginfo=pnginfo)
return path
@pytest.fixture
def tampered_chatgpt_png(tmp_path: Path) -> Path:
"""Add valid PNG metadata after signing so the C2PA asset hash no longer matches."""
source = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" / "chatgpt-1.png"
data = source.read_bytes()
iend = data.rfind(b"\x00\x00\x00\x00IEND")
assert iend >= 0
kind = b"tEXt"
payload = b"c2pa-test\x00benign post-signing metadata mutation"
chunk = (
struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
)
target = tmp_path / "tampered-chatgpt.png"
target.write_bytes(data[:iend] + chunk + data[iend:])
assert target.read_bytes() != data
return target
+9
View File
@@ -511,6 +511,15 @@ class TestSourceEvidenceHolder:
assert holder.visible_provenance() == api.visible_provenance(DOUBAO)
assert holder.has_invisible_target() == identify.has_invisible_target(DOUBAO)
def test_holder_preserves_invalid_c2pa_removal_hint(self, tampered_chatgpt_png):
from remove_ai_watermarks import api, identify
holder = api._SourceEvidence(tampered_chatgpt_png)
assert identify.identify(tampered_chatgpt_png, check_visible=False).ai_from_metadata is False
assert holder.has_invisible_target() is True
assert holder.has_invisible_target() == identify.has_invisible_target(tampered_chatgpt_png)
def test_extraction_failure_fails_safe_in_both_directions(self, monkeypatch, tmp_path):
"""No provenance means no relaxation; an unknown invisible target means SCRUB.
Leaving a watermark on a paid removal is worse than over-regenerating."""
+2
View File
@@ -723,6 +723,8 @@ class TestIdentifyCommand:
result = runner.invoke(main, ["identify", str(sample), "--no-visible"])
assert result.exit_code == 0
assert "AI-generated (fully synthetic)" in result.output
assert "C2PA validation: integrity=valid, signature=valid" in result.output
assert "signer trust=untrusted, signer validity=expired" in result.output
def test_identify_json_is_valid(self, runner, tmp_png_with_ai_metadata):
result = runner.invoke(main, ["identify", str(tmp_png_with_ai_metadata), "--no-visible", "--json"])
+111 -6
View File
@@ -39,6 +39,79 @@ SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "pr
class TestProvenanceEvidence:
def test_exact_ai_claim_generator_can_assert_ai_without_source_type(self, tmp_path: Path):
path = tmp_path / "firefly.png"
info = {
"has_c2pa": True,
"issuer": "Adobe",
"claim_generator": "Adobe_Firefly",
"ai_tool": "Firefly",
"c2pa_identity_ai": True,
"c2pa_validation_source": "reader",
"c2pa_validation_state": "Valid",
"c2pa_integrity": "valid",
"c2pa_signature": "valid",
"c2pa_signer_trust": "untrusted",
"c2pa_signer_validity": "valid",
"c2pa_validation_codes": ["assertion.dataHash.match", "claimSignature.validated"],
}
evidence = ProvenanceEvidence(
path=path,
c2pa_info=info,
ai_metadata={},
scan=b"jumb c2pa Adobe_Firefly",
iptc_ai_system=None,
aigc_label=None,
exif_generator=None,
xai_signature=False,
huggingface_job=None,
samsung_genai=None,
)
report = identify_from_evidence(evidence)
assert report.is_ai_generated is True
assert report.confidence == "medium"
assert report.platform == "Adobe Firefly"
def test_fully_validated_c2pa_claim_is_high_confidence(self, tmp_path: Path):
path = tmp_path / "validated.png"
info = {
"has_c2pa": True,
"issuer": "OpenAI",
"source_type": "trainedAlgorithmicMedia (AI-generated)",
"ai_source_kind": "generated",
"c2pa_validation_source": "reader",
"c2pa_validation_state": "Valid",
"c2pa_integrity": "valid",
"c2pa_signature": "valid",
"c2pa_signer_trust": "trusted",
"c2pa_signer_validity": "valid",
"c2pa_validation_codes": [
"assertion.dataHash.match",
"claimSignature.validated",
"signingCredential.trusted",
],
}
evidence = ProvenanceEvidence(
path=path,
c2pa_info=info,
ai_metadata={},
scan=b"jumb c2pa OpenAI trainedAlgorithmicMedia",
iptc_ai_system=None,
aigc_label=None,
exif_generator=None,
xai_signature=False,
huggingface_job=None,
samsung_genai=None,
)
report = identify_from_evidence(evidence)
assert report.is_ai_generated is True
assert report.confidence == "high"
assert report.platform == "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"
def test_external_metadata_record_builds_equivalent_evidence(self, tmp_path: Path):
path = tmp_path / "external.jpg"
signature = "A" * 64
@@ -428,12 +501,14 @@ class TestIdentifySamsungGalaxy:
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
return path
def test_galaxy_trained_source_is_high_ai(self, tmp_path: Path):
def test_galaxy_trained_source_is_unverified_ai(self, tmp_path: Path):
path = self._jpeg(tmp_path, "s25.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs trainedAlgorithmicMedia")
r = identify(path, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.confidence == "high"
assert r.confidence == "medium"
assert r.platform == "Samsung Galaxy (C2PA)"
assert r.c2pa_validation is None
assert any("without cryptographic validation" in caveat for caveat in r.caveats)
assert r.integrity_clashes == [] # device cert + AI source-type is legitimate, not a clash
def test_galaxy_genai_only_is_medium_ai(self, tmp_path: Path):
@@ -477,7 +552,7 @@ class TestIdentifyRealSamples:
def test_openai_chatgpt(self):
r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False)
assert r.is_ai_generated is True
assert r.confidence == "high"
assert r.confidence == "medium"
assert r.platform
assert "OpenAI" in r.platform
assert any("C2PA" in w for w in r.watermarks)
@@ -546,9 +621,39 @@ class TestIdentifyRealSamples:
# both invisible/metadata targets, so the diffusion scrub should run.
assert has_invisible_target(SAMPLES_DIR / "chatgpt-1.png") is True
assert has_invisible_target(SAMPLES_DIR / "mj-1.png") is True
# ai_from_metadata mirrors confidence == "high" and backs the helper.
# ai_from_metadata records scrub intent even when an untrusted signer
# makes the provenance verdict medium-confidence.
assert identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False).ai_from_metadata is True
def test_untrusted_but_intact_c2pa_is_medium_confidence(self):
report = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False, check_invisible=False)
assert report.is_ai_generated is True
assert report.confidence == "medium"
assert report.ai_from_metadata is True
assert report.c2pa_validation is not None
assert report.c2pa_validation["source"] == "reader"
assert report.c2pa_validation["state"] == "Invalid"
assert report.c2pa_validation["integrity"] == "valid"
assert report.c2pa_validation["signature"] == "valid"
assert report.c2pa_validation["signer_trust"] == "untrusted"
assert report.c2pa_validation["signer_validity"] == "expired"
assert "assertion.dataHash.match" in report.c2pa_validation["codes"]
assert any("not anchored" in caveat for caveat in report.caveats)
def test_hash_mismatch_does_not_confirm_origin_but_keeps_scrub_fail_safe(self, tampered_chatgpt_png: Path):
report = identify(tampered_chatgpt_png, check_visible=False, check_invisible=False)
assert report.is_ai_generated is None
assert report.platform is None
assert report.confidence == "none"
assert report.ai_source_kind is None
assert report.ai_from_metadata is False
assert report.c2pa_validation is not None
assert report.c2pa_validation["integrity"] == "invalid"
assert any("dataHash.mismatch" in clash for clash in report.integrity_clashes)
assert has_invisible_target(tampered_chatgpt_png) is True
def test_has_invisible_target_false_on_clean_photo(self, clean_photo: Path):
# No detectable invisible signal -> skip the scrub (do not degrade a clean image).
assert has_invisible_target(clean_photo) is False
@@ -566,13 +671,13 @@ class TestHasInvisibleTargetFailSafe:
"""The scrub gate fails SAFE: when a detector errors, it runs the removal."""
def test_detector_error_defaults_to_run(self, tmp_path: Path):
# If identify raises (a detector crash), the gate must return True so the
# If evidence evaluation raises (a detector crash), the gate must return True so the
# caller still attempts removal -- leaving a watermark on a paid removal is
# worse than over-regenerating. (Garbage bytes do NOT raise; identify returns
# a clean None verdict there, so that path correctly skips -- see below.)
bad = tmp_path / "x.png"
bad.write_bytes(b"not image bytes")
with patch("remove_ai_watermarks.identify.identify", side_effect=RuntimeError("boom")):
with patch("remove_ai_watermarks.identify._identify_from_evidence", side_effect=RuntimeError("boom")):
assert has_invisible_target(bad) is True
def test_unreadable_bytes_are_not_a_target(self, tmp_path: Path):
+4
View File
@@ -500,6 +500,10 @@ class TestGetAiMetadataRealSample:
assert "OpenAI" in meta["issuer"]
assert "synthid_watermark" not in meta
assert "trainedAlgorithmicMedia" in meta["source_type"]
assert meta["c2pa_integrity"] == "valid"
assert meta["c2pa_signature"] == "valid"
assert meta["c2pa_signer_trust"] == "untrusted"
assert meta["c2pa_signer_validity"] == "expired"
@pytest.mark.parametrize(
+123
View File
@@ -12,6 +12,7 @@ from PIL import Image
from remove_ai_watermarks._internal.c2pa import (
_parse_c2pa_chunk,
c2pa_info_from_manifest_store,
cbor_text_after,
extract_c2pa_chunk,
extract_c2pa_info,
@@ -153,6 +154,110 @@ class TestC2PA:
def test_c2pa_returns_false_for_non_png(self, tmp_jpeg_path):
assert not has_c2pa_metadata(tmp_jpeg_path)
def test_structured_extraction_ignores_unreachable_manifests(self):
store = {
"active_manifest": "active",
"manifests": {
"active": {
"signature_info": {"issuer": "Adobe"},
"assertions": [],
},
"unreachable": {
"signature_info": {"issuer": "OpenAI"},
"assertions": [
{
"label": "c2pa.actions.v2",
"data": {
"actions": [
{
"action": "c2pa.created",
"digitalSourceType": "trainedAlgorithmicMedia",
}
]
},
}
],
},
},
}
info = c2pa_info_from_manifest_store(store)
assert info["issuer"] == "Adobe"
assert "source_type" not in info
assert "ai_source_kind" not in info
assert "c2pa_identity_ai" not in info
def test_reachable_ingredient_claim_generator_can_assert_ai(self):
store = {
"active_manifest": "update",
"manifests": {
"update": {
"claim_generator": "c2pa-tool/0.1.0",
"ingredients": [{"active_manifest": "created"}],
"assertions": [],
},
"created": {
"claim_generator": "Dreamina/7.5.0",
"assertions": [],
},
},
}
info = c2pa_info_from_manifest_store(store)
assert info["ai_tool"] == "Dreamina"
assert info["c2pa_identity_ai"] is True
def test_invalid_ingredient_does_not_taint_active_validation_or_supply_claims(self):
store = {
"active_manifest": "update",
"validation_results": {
"activeManifest": {
"success": [
{"code": "assertion.dataHash.match"},
{"code": "claimSignature.validated"},
],
"failure": [{"code": "signingCredential.untrusted"}],
},
"ingredientDeltas": [
{
"validationDeltas": {
"failure": [{"code": "assertion.dataHash.mismatch"}],
}
}
],
},
"manifests": {
"update": {
"claim_generator": "c2pa-tool/0.1.0",
"ingredients": [
{
"active_manifest": "created",
"validation_results": {
"activeManifest": {
"failure": [{"code": "assertion.dataHash.mismatch"}],
}
},
}
],
"assertions": [],
},
"created": {
"claim_generator": "Dreamina/7.5.0",
"assertions": [],
},
},
}
info = c2pa_info_from_manifest_store(store)
assert info["c2pa_integrity"] == "valid"
assert info["c2pa_signature"] == "valid"
assert info["c2pa_signer_trust"] == "untrusted"
assert "ai_tool" not in info
assert "c2pa_identity_ai" not in info
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
CURRENT_OPENAI_SAMPLE = (
@@ -227,6 +332,22 @@ class TestC2PARealSamples:
# Structured claim generator is exact, not a CBOR-scanned best-effort.
assert info["claim_generator"] == "ChatGPT"
def test_reader_reports_intact_but_untrusted_credentials(self):
info = extract_c2pa_info(SAMPLES_DIR / "chatgpt-1.png")
assert info["c2pa_integrity"] == "valid"
assert info["c2pa_signature"] == "valid"
assert info["c2pa_signer_trust"] == "untrusted"
assert info["c2pa_signer_validity"] == "expired"
assert "assertion.dataHash.match" in info["c2pa_validation_codes"]
def test_reader_reports_post_signing_container_mutation(self, tampered_chatgpt_png):
info = extract_c2pa_info(tampered_chatgpt_png)
assert info["c2pa_integrity"] == "invalid"
assert info["c2pa_signature"] == "valid"
assert "assertion.dataHash.mismatch" in info["c2pa_validation_codes"]
def test_fallback_to_png_parser_when_reader_unavailable(self, monkeypatch):
"""With the reader disabled, the hand-rolled PNG parser still works."""
from remove_ai_watermarks._internal import c2pa
@@ -237,6 +358,8 @@ class TestC2PARealSamples:
assert "OpenAI" in info["issuer"]
assert "trainedAlgorithmicMedia" in info["source_type"]
assert "synthid_watermark" not in info
assert info["c2pa_integrity"] == "unknown"
assert info["c2pa_validation_source"] == "fallback"
class TestC2PAInjectValidation:
+8
View File
@@ -293,6 +293,14 @@ class TestReportTransport:
assert convenience == explicit
def test_c2pa_validation_survives_the_portable_record(self, tampered_chatgpt_png: Path):
direct = identify(tampered_chatgpt_png, check_visible=False, check_invisible=False)
portable = identify_metadata_record(collect_metadata_record(tampered_chatgpt_png), path=tampered_chatgpt_png)
assert portable == direct
assert portable.c2pa_validation is not None
assert portable.c2pa_validation["integrity"] == "invalid"
def test_a_webp_record_matches(tmp_path: Path):
"""RIFF has its own walk; a chunk kept or dropped wrongly shows up here."""