diff --git a/docs/module-internals.md b/docs/module-internals.md index 45b6c4a..adee955 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -667,6 +667,19 @@ metadata extraction from verdict logic: further to the manifest store the container locates (`_c2pa_store_bytes`: JPEG APP11, PNG `caBX`): an XMP packet names Adobe in its toolkit string, and a vivo X300 capture was labeled "C2PA signer: Adobe" from it (2026-09-26). +- Registry tokens are matched two ways, by what the text is. Decoded manifest + strings (signer, claim generator, software agent, and the video metadata + markers) use `registry_word_matches`: the token must stand as a whole word, so + `Canva` no longer matches `Nova Canvas`. Raw bytes cannot use that rule. Over + every C2PA file in the local corpus (1,379 files, 2026-09-26), 374 of 37,325 + token matches in the manifest store had a letter or digit beside them, nearly all + of it framing: a CBOR text header before the token (`iMicrosoft`, 0x69 = length + 9) or a DER tag after it (`OpenAI1`, `Sora0`). The only continuation that named + the wrong vendor was `Canva|s`; `Adobe|Product` sat in an Adobe manifest. So raw + bytes decide nothing once the reader has decoded the manifest: an unregistered + signer stays unknown rather than being re-derived from bytes, which is how the + Amazon Bedrock image read as Canva. The byte scan remains only for manifests + the reader cannot open (1 of the 1,379). - `identify_from_evidence` evaluates that evidence without reopening the source. Rules that decide a verdict live here, not in extraction: extraction has two implementations, and a rule in only one of them is a rule the other lacks. The diff --git a/src/remove_ai_watermarks/_internal/c2pa.py b/src/remove_ai_watermarks/_internal/c2pa.py index 81d8a8d..67dc04a 100644 --- a/src/remove_ai_watermarks/_internal/c2pa.py +++ b/src/remove_ai_watermarks/_internal/c2pa.py @@ -484,7 +484,7 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: def add_tool_matches(value: str, *, asserts_ai: bool = False) -> None: nonlocal claim_generator_asserts_ai - matches = _ordered_matches(value.encode(), C2PA_AI_TOOLS) + matches = registry_word_matches(value, C2PA_AI_TOOLS) tools.extend(matches) if asserts_ai and matches: claim_generator_asserts_ai = True @@ -496,7 +496,7 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: for key in ("issuer", "common_name", "certificate_issuer"): value = signature.get(key) if isinstance(value, str): - issuers.extend(_ordered_matches(value.encode(), C2PA_ISSUERS)) + issuers.extend(registry_word_matches(value, C2PA_ISSUERS)) identity_strings.append(value) ingredient_values = manifest.get("ingredients") @@ -713,6 +713,26 @@ def _ordered_matches(buffer: bytes, registry: dict[bytes, str]) -> list[str]: return list(dict.fromkeys(label for token, label in registry.items() if token in buffer)) +def registry_word_matches(text: str, registry: dict[bytes, str]) -> list[str]: + """Registry labels whose token appears in ``text`` as a whole word. + + For decoded manifest strings only. A raw substring match named Canva for the + "Nova Canvas" software agent of an Amazon Bedrock image; inside a decoded string + a letter or digit next to the token is always a longer word. Raw manifest bytes + cannot use this rule: CBOR length headers and DER tags sit next to real tokens + as letters and digits (``iMicrosoft``, ``OpenAI1``), measured over every C2PA + file in the local corpus on 2026-09-26. + """ + return list( + dict.fromkeys(label for token, label in registry.items() if _word_pattern(token).search(text) is not None) + ) + + +@functools.cache +def _word_pattern(token: bytes) -> re.Pattern[str]: + return re.compile(r"(? bool: issuers = _ordered_matches(buffer, C2PA_ISSUERS) tools = _ordered_matches(buffer, C2PA_AI_TOOLS) diff --git a/src/remove_ai_watermarks/_internal/constants.py b/src/remove_ai_watermarks/_internal/constants.py index 9556251..90daaf4 100644 --- a/src/remove_ai_watermarks/_internal/constants.py +++ b/src/remove_ai_watermarks/_internal/constants.py @@ -113,6 +113,9 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = ( _vendor(b"Amazon Web Services", "Amazon", "Amazon Bedrock (Nova)", "Amazon"), _vendor(b"Canva", "Canva", "Canva (Magic Media)", "Canva"), _vendor(b"Eleven Labs", "ElevenLabs", "ElevenLabs", "ElevenLabs"), + # fal signs as "fal - Features & Labels Inc." (common name "fal.ai"); "fal-ai" is + # its claim-generator and software-agent prefix, which only the byte fallback reads. + _vendor(b"Features & Labels", "fal.ai", "fal.ai", "fal.ai", asserts_ai=True), _vendor(b"fal-ai", "fal.ai", "fal.ai", "fal.ai", asserts_ai=True), _vendor(b"Bria", "Bria Artificial Intelligence", "Bria AI", "Bria", asserts_ai=True), # Ideogram signs its downloads' Content Credentials with "Ideogram, Inc"; the diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 957ab4e..0ff5da9 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -1261,7 +1261,16 @@ def _identify_from_evidence( # by substring made the displayed reason a second, looser rule than the verdict. failed_c2pa_codes = [str(code) for code in cast("list[object]", info.get("c2pa_failed_codes", []))] store = _c2pa_store_bytes(head, region) - issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(store) + # A manifest the reader decoded names its signer in ``info`` or not at all: an + # unregistered signer must not be re-derived from raw bytes, where the Canva token + # sits inside an Amazon Bedrock "Nova Canvas" agent. Bytes decide only when the + # reader could not open the manifest. + if info.get("issuer"): + issuers = [info["issuer"]] + elif info.get("c2pa_validation_source") == "reader": + issuers = [] + else: + issuers = _issuers_in(store) signer_label = _signer_platform(store, issuers) # Full AI generation (trainedAlgorithmicMedia) vs an AI-enhanced real photo # (compositeWithTrainedAlgorithmicMedia). The structured kind is parsed once in diff --git a/src/remove_ai_watermarks/video.py b/src/remove_ai_watermarks/video.py index b0be0ee..7da19d1 100644 --- a/src/remove_ai_watermarks/video.py +++ b/src/remove_ai_watermarks/video.py @@ -393,14 +393,19 @@ def _video_markers_claim_ai(markers: dict[str, str]) -> bool: def _platform_from_video_metadata(markers: dict[str, str]) -> str | None: """Map supported C2PA-derived marker text to its generating platform.""" + from remove_ai_watermarks._internal.c2pa import registry_word_matches from remove_ai_watermarks._internal.constants import C2PA_AI_VENDORS marker_text = "\n".join(markers.values()).casefold() if not marker_text: return None - for vendor in C2PA_AI_VENDORS: - if vendor.platform is not None and vendor.needle is not None and vendor.needle.casefold() in marker_text: - return vendor.platform + needles = { + vendor.needle.casefold().encode(): vendor.platform + for vendor in C2PA_AI_VENDORS + if vendor.platform is not None and vendor.needle is not None + } + if hits := registry_word_matches(marker_text, needles): + return hits[0] if "aigc_label" in markers: return _tc260_video_platform(markers.get("aigc_producer", "")) or ( "China AIGC-labeled content (TC260 standard)" diff --git a/tests/test_identify.py b/tests/test_identify.py index d884ca5..91d15bc 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -2199,6 +2199,72 @@ class TestRegistryScansSkipTheCodedPixels: assert _metadata_region(blob) == blob +class TestRegistryTokenMatching: + """Registry tokens match decoded manifest strings as whole words, never raw bytes a reader decoded.""" + + def test_word_matches_reject_a_token_inside_a_longer_word(self): + from remove_ai_watermarks._internal.c2pa import registry_word_matches + from remove_ai_watermarks._internal.constants import C2PA_AI_TOOLS, C2PA_ISSUERS + + assert "Canva" not in registry_word_matches("Nova Canvas", C2PA_ISSUERS) + assert registry_word_matches("Canva", C2PA_ISSUERS) == ["Canva"] + assert "Adobe" in registry_word_matches("Adobe_Firefly", C2PA_ISSUERS) + assert "Firefly" in registry_word_matches("Adobe_Firefly", C2PA_AI_TOOLS) + + def test_decoded_manifest_with_unknown_signer_is_not_rescanned_as_bytes(self, tmp_path: Path): + # Before the Amazon row, a decoded Bedrock manifest with an unregistered signer + # fell back to a raw byte scan, which found "Canva" in the "Nova Canvas" agent. + store = { + "active_manifest": "m", + "validation_results": { + "activeManifest": { + "success": [{"code": "assertion.dataHash.match"}, {"code": "claimSignature.validated"}], + "failure": [], + } + }, + "manifests": { + "m": { + "signature_info": {"issuer": "Example Studio, Inc.", "common_name": "Example Studio, Inc."}, + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.created", + "softwareAgent": "Nova Canvas", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/" + "trainedAlgorithmicMedia", + } + ] + }, + } + ], + } + }, + } + info = c2pa_info_from_manifest_store(store) + assert info["c2pa_validation_source"] == "reader" + assert not info.get("issuer") + evidence = ProvenanceEvidence( + path=tmp_path / "unknown-signer.png", + c2pa_info=info, + ai_metadata={}, + scan=b"jumb c2pa " + json.dumps(store).encode(), + 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.platform != "Canva (Magic Media)" + assert not any("Canva" in mark for mark in report.watermarks) + + class TestGooglePhotosSynthIdScope: """A Google Photos AI edit reports SynthID without a recorded SynthID action.