mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 05:20:46 +02:00
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:
1 parent
fb92c7dbc8
commit
b02eb12cb2
6 files changed
+122
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user