Match registry tokens as whole words in decoded manifest strings

Registry tokens were raw substrings, so the Canva token matched the "Nova
Canvas" agent of an Amazon Bedrock image. Raw manifest bytes cannot take a
word-boundary rule: across the 1,379 C2PA files in the local corpus, 374 of
37,325 store matches had a letter or digit beside them, nearly all CBOR text
headers and DER tags. Decoded strings have no such framing.

- Match signer, claim-generator, agent and video-marker strings with
  registry_word_matches.
- When the reader decoded the manifest, never re-derive the signer from raw
  bytes: an unregistered signer stays unknown. Bytes decide only for
  manifests the reader cannot open.
- Add fal's signer "Features & Labels"; its attribution had relied on the byte
  fallback finding the "fal-ai" generator prefix.

Verdicts over 468 images and 365 videos are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Victor KuznetsovandClaude Opus 5.5 committed 2026-09-26 17:38:35 -07:00
1 parent fb92c7dbc8
commit b02eb12cb2
6 files changed
+122 -6

No files matched your search

+13
View File
@@ -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
+22 -2
View File
@@ -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"(?<![A-Za-z0-9])" + re.escape(token.decode("utf-8")) + r"(?![A-Za-z0-9])")
def _populate_registry_fields(buffer: bytes, info: dict[str, Any]) -> bool:
issuers = _ordered_matches(buffer, C2PA_ISSUERS)
tools = _ordered_matches(buffer, C2PA_AI_TOOLS)
@@ -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
+10 -1
View File
@@ -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
+8 -3
View File
@@ -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)"
+66
View File
@@ -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.