diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ba3774a..b013148 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,6 +20,8 @@ jobs: - uses: astral-sh/setup-uv@v7 - name: Sync dev environment run: uv sync --frozen --extra dev + - name: Check C2PA soft-binding registry snapshot + run: uv run python scripts/sync_c2pa_soft_bindings.py --check - name: Ruff lint run: uv run ruff check - name: Ruff format check diff --git a/docs/cli.md b/docs/cli.md index 3a33b8c..ab059f7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -49,8 +49,10 @@ unanchored or expired signer is reported as a caveat rather than a lower score, because no trust anchor list ships with the reader. A failed asset binding or signature, or a revoked signing credential, does not confirm the claimed origin. When a structured C2PA soft binding is present, the report also names its exact algorithm and -signed value; removing the manifest does not remove the referenced pixel -watermark or content fingerprint. +signed value. Removing the manifest does not remove a referenced pixel +watermark, and a referenced content fingerprint may still be recomputed. A +fingerprint is reported as a durable-provenance signal, not as a watermark or a +pixel-removal target. Machine readable output: diff --git a/docs/module-internals.md b/docs/module-internals.md index df1da98..7b17202 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -419,13 +419,24 @@ type. Unreachable manifests remain excluded. Reachable `c2pa.soft-binding*` assertions retain their exact `alg` and bounded, printable block `value` in addition to the normalized vendor label. A block value without its algorithm is not surfaced because it cannot be attributed to a -decoder or registry entry. `com.microsoft.invismark.1` uses that value as the +decoder or registry entry. The labels and watermark/fingerprint classification +come from the generated `_internal/_generated_c2pa_soft_bindings.py` snapshot of +the official C2PA registry. Refresh it with +`uv run python scripts/sync_c2pa_soft_bindings.py`; the command validates the +upstream schema invariants, records the exact source revision and writes a +deterministic module. Runtime inspection remains offline, and registry membership +is name-only evidence rather than proof that a compatible decoder ships. + +`com.microsoft.invismark.1` uses its block value as the pixel-watermark identifier in Microsoft Paint output. An InvisMark soft binding keeps the invisible-removal gate fail-safe even when the C2PA asset binding has since become invalid, because metadata damage does not prove the pixel carrier disappeared. `identify` retains the generic `soft_binding` signal for schema-1 compatibility and adds `invismark` as the stable pixel-removal signal. -Content-fingerprint soft bindings do not trigger pixel regeneration. +Registry entries of type `fingerprint` remain durable-provenance signals but are +not added to the watermark inventory, do not trigger pixel regeneration, and do +not suppress independently established SynthID watermark evidence. A registered +watermark or an unknown soft-binding algorithm keeps that inference fail-safe. The SDK default enables trust verification but supplies no production trust anchors. Consequently, an installation without an explicitly maintained C2PA diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 00367fe..6c55bd8 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -100,6 +100,15 @@ dimensions and as caveats, since no trust anchor list ships to evaluate them. Fallback claims, which validate nothing, remain medium-confidence, while a failed binding or signature or a revoked credential contributes no origin verdict. +C2PA soft-binding names and their official `watermark` or `fingerprint` type come +from a generated snapshot of the +[C2PA registry](https://github.com/c2pa-org/softbinding-algorithm-list). A registry +match names the declared algorithm but does not imply local payload decoding. +`identify` keeps both kinds in the generic `soft_binding` signal; only watermark +entries enter the watermark inventory, while a fingerprint remains a +re-linkability warning, never selects pixel regeneration, and does not suppress +independent SynthID evidence. + ## File and container formats Pixel based image commands discover these extensions: diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 11f5fa2..e6002b9 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -65,7 +65,7 @@ payloads. Removal remuxes either container through ffmpeg with stream copy. DWT-DCT decoder only applies when the producing pipeline actually ran its encoder and the carrier remains decodable. - **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video). No local detector possible; treat like SynthID. -- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, preserve the structured assertion's signed `value`, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest stay name-only unless a compatible decoder is independently verified. +- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We READ the soft-binding `alg`, preserve the structured assertion's signed `value`, and generate the display/type registry from the official C2PA JSON with `scripts/sync_c2pa_soft_bindings.py`. The packaged snapshot keeps runtime inspection offline and pins the exact upstream revision. Registration is name-only support: Adobe TrustMark remains the one locally decoded scheme (`trustmark_detector`) unless another compatible decoder is independently verified. Fingerprint entries remain re-linkability signals and are not mislabeled as pixel watermarks. - **Microsoft Paint and Photos InvisMark (reverse-engineered 2026-08-20):** Paint receives a per-generation GUID from remote prompt moderation, embeds it into locally generated pixels, and records the same value in `c2pa.soft-binding` under `com.microsoft.invismark.1`. The C2PA soft-binding registry independently identifies that algorithm as Microsoft Responsible AI InvisMark for image and video. Paint's 144-bit writer framing does not match the public repository's 100-bit pretrained checkpoint interface, so compatibility is not assumed. The parser reports the signed identifier; there is no validated local pixel decoder. Microsoft's external Content Provenance Detection API is the removal oracle because it reports `Watermark` separately from `C2PA`; a metadata-stripped, pixel-identical control must remain watermark-positive before an output-negative result is attributed to pixel removal. The no-account web page for the same check is (its `Inconclusive` verdict is weaker than the API's separate watermark result). Sources: https://xusheng.dev/posts/reversing/mspaint_invisible_watermark/main/, https://github.com/c2pa-org/softbinding-algorithm-list/blob/main/softbinding-algorithm-list.json, and https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection - **Built in the dated batch:** soft-binding vendor detection, IPTC Photo Metadata AI-disclosure fields, C2PA detection and stripping for supported diff --git a/maintain.sh b/maintain.sh index 0e913c5..0a5462f 100755 --- a/maintain.sh +++ b/maintain.sh @@ -21,6 +21,7 @@ if ! grep -qE "No vulnerabilities or maintenance issues detected|All dependencie echo "maintain.sh: uv-secure reported a finding or failed before its verdict -- triage before committing." >&2 exit 1 fi +uv run python scripts/sync_c2pa_soft_bindings.py --check uv run ruff check --fix uv run ruff format # Scoped to src/: a full-project pyright run OOM-crashes node on this ML-heavy diff --git a/scripts/README.md b/scripts/README.md index 1342e8e..fd49726 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -68,6 +68,12 @@ explicitly names a tracked canonical result. | --- | --- | | `render_pill_silhouette.py` | Render the synthetic Jimeng pill silhouette. | | `render_vendor_silhouettes.py` | Render synthetic vendor text-mark silhouettes. | +| `sync_c2pa_soft_bindings.py` | Validate the official C2PA soft-binding registry and refresh its pinned runtime snapshot. | + +`maintain.sh` and the CI lint job run `sync_c2pa_soft_bindings.py --check`, so an +upstream registry change fails with the refresh command instead of silently +leaving new algorithms unnamed. The check and runtime parser are read-only; +running the sync command without `--check` is the explicit snapshot update. ## Shared helpers diff --git a/scripts/sync_c2pa_soft_bindings.py b/scripts/sync_c2pa_soft_bindings.py new file mode 100644 index 0000000..046cd11 --- /dev/null +++ b/scripts/sync_c2pa_soft_bindings.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Synchronize the packaged C2PA soft-binding registry snapshot.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import re +import sys +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, cast +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +log = logging.getLogger(__name__) + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SOURCE_URL = ( + "https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json" +) +PINNED_SOURCE_URL = ( + "https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/{revision}/softbinding-algorithm-list.json" +) +DEFAULT_REVISION_URL = ( + "https://api.github.com/repos/c2pa-org/softbinding-algorithm-list/commits" + "?path=softbinding-algorithm-list.json&per_page=1" +) +DEFAULT_OUTPUT = REPOSITORY_ROOT / "src/remove_ai_watermarks/_internal/_generated_c2pa_soft_bindings.py" +SOURCE_LICENSE = "CC BY 4.0" +SOURCE_LICENSE_URL = "https://creativecommons.org/licenses/by/4.0/" +_ALGORITHM_PATTERN = re.compile(r"^[A-Za-z]{2,63}(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+\.?$") +_DECODED_MEDIA_TYPES = frozenset({"application", "audio", "image", "model", "text", "video"}) + + +@dataclass(frozen=True, slots=True) +class RegistryRow: + """The runtime subset of one validated upstream registry entry.""" + + identifier: int + algorithm: str + kind: str + decoded_media_types: tuple[str, ...] + encoded_media_types: tuple[str, ...] + display_label: str + date_entered: str + resolution_apis: tuple[str, ...] + deprecated: bool + + +def _read_bytes(source: str) -> bytes: + parsed = urlparse(source) + if parsed.scheme in {"http", "https"}: + request = Request(source, headers={"User-Agent": "remove-ai-watermarks-c2pa-registry-sync"}) + with urlopen(request, timeout=30) as response: + return response.read() + return Path(source).read_bytes() + + +def _read_json(source: str) -> Any: + return json.loads(_read_bytes(source)) + + +def _require_text(value: object, field: str) -> str: + if not isinstance(value, str) or not value or not value.isprintable(): + raise ValueError(f"{field} must be a non-empty printable string") + return value + + +def _media_types(entry: dict[str, Any], identifier: int) -> tuple[tuple[str, ...], tuple[str, ...]]: + decoded_value = entry.get("decodedMediaTypes", []) + encoded_value = entry.get("encodedMediaTypes", []) + if not isinstance(decoded_value, list) or not all(isinstance(item, str) for item in decoded_value): + raise ValueError(f"entry {identifier}: decodedMediaTypes must be a string array") + if not isinstance(encoded_value, list) or not all(isinstance(item, str) for item in encoded_value): + raise ValueError(f"entry {identifier}: encodedMediaTypes must be a string array") + decoded = tuple(cast("list[str]", decoded_value)) + encoded = tuple(cast("list[str]", encoded_value)) + if not decoded and not encoded: + raise ValueError(f"entry {identifier}: one media-type array is required") + unknown = set(decoded) - _DECODED_MEDIA_TYPES + if unknown: + raise ValueError(f"entry {identifier}: unsupported decoded media types: {sorted(unknown)}") + if any("/" not in media_type or any(character.isspace() for character in media_type) for media_type in encoded): + raise ValueError(f"entry {identifier}: invalid encoded media type") + return decoded, encoded + + +def _validated_urls(value: object, field: str) -> tuple[str, ...]: + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise ValueError(f"{field} must be a string array") + urls = tuple(cast("list[str]", value)) + if any(not _is_http_url(url) for url in urls): + raise ValueError(f"{field} contains an invalid URL") + return urls + + +def _is_http_url(value: str) -> bool: + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def _display_label(description: str) -> str: + """Keep the official description's compact first sentence as its display label.""" + return description.partition(". ")[0].rstrip(".") + + +def parse_registry(payload: object) -> tuple[RegistryRow, ...]: + """Validate the upstream shape and return its normalized runtime rows.""" + if not isinstance(payload, list): + raise ValueError("registry root must be an array") + rows: list[RegistryRow] = [] + for raw_entry in cast("list[object]", payload): + if not isinstance(raw_entry, dict): + raise ValueError("every registry entry must be an object") + entry = cast("dict[str, Any]", raw_entry) + identifier = entry.get("identifier") + if not isinstance(identifier, int) or isinstance(identifier, bool) or not 0 <= identifier <= 65535: + raise ValueError("identifier must be an integer from 0 through 65535") + algorithm = _require_text(entry.get("alg"), f"entry {identifier}: alg") + if _ALGORITHM_PATTERN.fullmatch(algorithm) is None: + raise ValueError(f"entry {identifier}: invalid alg {algorithm!r}") + kind = entry.get("type") + if kind not in {"watermark", "fingerprint"}: + raise ValueError(f"entry {identifier}: invalid type {kind!r}") + decoded, encoded = _media_types(entry, identifier) + metadata_value = entry.get("entryMetadata") + if not isinstance(metadata_value, dict): + raise ValueError(f"entry {identifier}: entryMetadata must be an object") + metadata = cast("dict[str, Any]", metadata_value) + description = _require_text(metadata.get("description"), f"entry {identifier}: description") + date_entered = _require_text(metadata.get("dateEntered"), f"entry {identifier}: dateEntered") + try: + datetime.fromisoformat(date_entered.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError(f"entry {identifier}: invalid dateEntered {date_entered!r}") from error + contact = _require_text(metadata.get("contact"), f"entry {identifier}: contact") + if "@" not in contact or any(character.isspace() for character in contact): + raise ValueError(f"entry {identifier}: invalid contact") + information_url = _require_text(metadata.get("informationalUrl"), f"entry {identifier}: informationalUrl") + if not _is_http_url(information_url): + raise ValueError(f"entry {identifier}: invalid informationalUrl") + resolution_apis = _validated_urls( + entry.get("softBindingResolutionApis", []), + f"entry {identifier}: softBindingResolutionApis", + ) + deprecated = entry.get("deprecated", False) + if not isinstance(deprecated, bool): + raise ValueError(f"entry {identifier}: deprecated must be a boolean") + rows.append( + RegistryRow( + identifier=identifier, + algorithm=algorithm, + kind=cast("str", kind), + decoded_media_types=decoded, + encoded_media_types=encoded, + display_label=_display_label(description), + date_entered=date_entered, + resolution_apis=resolution_apis, + deprecated=deprecated, + ) + ) + + identifiers = [row.identifier for row in rows] + algorithms = [row.algorithm for row in rows] + if identifiers != sorted(identifiers): + raise ValueError("registry entries must be ordered by identifier") + if len(identifiers) != len(set(identifiers)): + raise ValueError("registry identifiers must be unique") + if len(algorithms) != len(set(algorithms)): + raise ValueError("registry algorithms must be unique") + return tuple(rows) + + +def _latest_revision() -> str: + revisions = _read_json(DEFAULT_REVISION_URL) + if not isinstance(revisions, list) or not revisions or not isinstance(revisions[0], dict): + raise ValueError("GitHub did not return a registry revision") + revision = cast("dict[str, Any]", revisions[0]).get("sha") + if not isinstance(revision, str) or re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise ValueError("GitHub returned an invalid registry revision") + return revision + + +def _source_snapshot(source: str, explicit_revision: str | None) -> tuple[bytes, str]: + if source == DEFAULT_SOURCE_URL: + revision = explicit_revision or _latest_revision() + if re.fullmatch(r"[0-9a-f]{40}", revision) is None: + raise ValueError("the official source revision must be a 40-character commit SHA") + # Fetch through the immutable revision. Reading `main` before its revision + # creates a race where the data and recorded commit name different states. + return _read_bytes(PINNED_SOURCE_URL.format(revision=revision)), revision + source_bytes = _read_bytes(source) + revision = explicit_revision or f"sha256:{hashlib.sha256(source_bytes).hexdigest()}" + return source_bytes, revision + + +def _quoted(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def _string_tuple(values: tuple[str, ...]) -> str: + if not values: + return "()" + body = ", ".join(_quoted(value) for value in values) + return f"({body}{',' if len(values) == 1 else ''})" + + +def _string_field(value: str, *, indent: str = " ") -> list[str]: + literal = _quoted(value) + if len(indent) + len(literal) + 1 <= 120: + return [f"{indent}{literal},"] + + chunks: list[str] = [] + remaining = value + while len(_quoted(remaining)) > 92: + boundary = remaining.rfind(" ", 0, 88) + if boundary <= 0: + boundary = 88 + chunks.append(remaining[: boundary + (boundary < len(remaining) and remaining[boundary] == " ")]) + remaining = remaining[len(chunks[-1]) :] + chunks.append(remaining) + return [f"{indent}(", *(f"{indent} {_quoted(chunk)}" for chunk in chunks), f"{indent}),"] + + +def render_module(rows: tuple[RegistryRow, ...], *, source: str, revision: str) -> str: + """Render a deterministic, import-only Python snapshot.""" + lines = [ + '"""Generated C2PA soft-binding registry; do not edit by hand.', + "", + f"Source: {source}", + f"Revision: {revision}", + f"Source license: {SOURCE_LICENSE} ({SOURCE_LICENSE_URL})", + "Changes: validated, ordered, and reduced to fields used at runtime; descriptions", + "are shortened to their first sentence for display.", + '"""', + "", + "from __future__ import annotations", + "", + "C2PA_SOFT_BINDING_SOURCE_URL = (", + f" {_quoted(source)}", + ")", + f"C2PA_SOFT_BINDING_SOURCE_REVISION = {_quoted(revision)}", + f"C2PA_SOFT_BINDING_SOURCE_LICENSE = {_quoted(SOURCE_LICENSE)}", + "", + "C2PA_SOFT_BINDING_ROWS: tuple[", + " tuple[int, str, str, tuple[str, ...], tuple[str, ...], str, str, tuple[str, ...], bool], ...", + "] = (", + ] + for row in rows: + lines.extend( + [ + " (", + f" {row.identifier},", + f" {_quoted(row.algorithm)},", + f" {_quoted(row.kind)},", + f" {_string_tuple(row.decoded_media_types)},", + f" {_string_tuple(row.encoded_media_types)},", + ] + ) + lines.extend(_string_field(row.display_label)) + lines.extend( + [ + f" {_quoted(row.date_entered)},", + f" {_string_tuple(row.resolution_apis)},", + f" {row.deprecated!r},", + " ),", + ] + ) + lines.extend((")", "")) + return "\n".join(lines) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", default=DEFAULT_SOURCE_URL, help="Upstream JSON URL or local file") + parser.add_argument("--revision", help="Source revision; inferred for the official URL") + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="Generated Python module") + parser.add_argument("--check", action="store_true", help="Fail if the generated snapshot is stale") + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + try: + source_bytes, revision = _source_snapshot(args.source, args.revision) + rows = parse_registry(json.loads(source_bytes)) + rendered = render_module(rows, source=args.source, revision=revision) + if args.check: + if not args.output.exists() or args.output.read_text() != rendered: + log.error("C2PA soft-binding snapshot is stale; run this script without --check") + return 1 + log.info("C2PA soft-binding snapshot is current: %s entries at %s", len(rows), revision) + return 0 + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered) + log.info("Updated %s with %s entries at %s", args.output, len(rows), revision) + return 0 + except (OSError, ValueError, json.JSONDecodeError) as error: + log.error("C2PA soft-binding sync failed: %s", error) + return 1 + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + sys.exit(main()) diff --git a/src/remove_ai_watermarks/_internal/_generated_c2pa_soft_bindings.py b/src/remove_ai_watermarks/_internal/_generated_c2pa_soft_bindings.py new file mode 100644 index 0000000..373d2f8 --- /dev/null +++ b/src/remove_ai_watermarks/_internal/_generated_c2pa_soft_bindings.py @@ -0,0 +1,635 @@ +"""Generated C2PA soft-binding registry; do not edit by hand. + +Source: https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json +Revision: c0218cd628c9163ad62a08c3deff5a10fa0e0d0b +Source license: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/) +Changes: validated, ordered, and reduced to fields used at runtime; descriptions +are shortened to their first sentence for display. +""" + +from __future__ import annotations + +C2PA_SOFT_BINDING_SOURCE_URL = ( + "https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json" +) +C2PA_SOFT_BINDING_SOURCE_REVISION = "c0218cd628c9163ad62a08c3deff5a10fa0e0d0b" +C2PA_SOFT_BINDING_SOURCE_LICENSE = "CC BY 4.0" + +C2PA_SOFT_BINDING_ROWS: tuple[ + tuple[int, str, str, tuple[str, ...], tuple[str, ...], str, str, tuple[str, ...], bool], ... +] = ( + ( + 1, + "com.digimarc.validate.1", + "watermark", + ("audio", "video", "text", "image"), + (), + "Digimarc Validate Digital Watermarking algorithm", + "2024-05-17T17:00:00.000Z", + (), + False, + ), + ( + 2, + "org.atsc.a336", + "watermark", + ("audio", "video", "image"), + (), + "ATSC watermarking (A/334, A/335, A/336)", + "2024-05-17T15:43:00.000Z", + (), + False, + ), + ( + 3, + "io.iscc.v0", + "fingerprint", + ("text", "image", "audio", "video", "application"), + (), + "ISO 24138 - International Standard Content Code (ISCC) V0 algorithm", + "2024-05-17T16:00:00Z", + (), + False, + ), + ( + 4, + "com.adobe.trustmark.Q", + "watermark", + ("image",), + (), + "Adobe Trustmark variant Q", + "2024-05-17T17:00:00.000Z", + (), + False, + ), + ( + 5, + "com.adobe.trustmark.C", + "watermark", + ("image",), + (), + "Adobe Trustmark variant C", + "2024-05-17T17:00:00.000Z", + (), + False, + ), + ( + 6, + "com.adobe.icn.dense", + "fingerprint", + ("image",), + (), + "Adobe Image Comparator Network Dense Fingerprint", + "2024-05-17T17:00:00.000Z", + (), + False, + ), + ( + 7, + "ai.steg.api", + "watermark", + ("image", "video", "application"), + (), + "Steg.AI invisible watermarking", + "2024-05-20T10:50:00.000Z", + (), + False, + ), + ( + 8, + "ai.trufo.pawprint.watermark", + "watermark", + ("image", "audio", "video"), + (), + "PawPrint neural watermarking for images, audio, and video", + "2024-08-14T15:00:00.000Z", + ("https://c2pa.trufo.ai/v1",), + False, + ), + ( + 9, + "ai.trufo.pawprint.fingerprint", + "fingerprint", + ("image", "audio", "video"), + (), + "PawPrint neural fingerprinting for images, audio, and video", + "2024-08-14T15:00:00.000Z", + ("https://c2pa.trufo.ai/v1",), + False, + ), + ( + 10, + "app.overlai.watermark.1", + "watermark", + ("image",), + (), + "Overlai Watermark version 1", + "2024-08-14T16:00:00.000Z", + (), + False, + ), + ( + 11, + "tv.kinetiq.watercast.48.1", + "watermark", + ("video",), + (), + "Teletrax Watermarking algorithm", + "2024-10-16T17:00:00.000Z", + (), + False, + ), + ( + 12, + "castLabs.watermark.1", + "watermark", + ("video", "image", "audio"), + (), + "castLabs Single Frame Forensic Watermarking", + "2024-11-13T12:00:00.000Z", + (), + False, + ), + ( + 13, + "com.adobe.trustmark.P", + "watermark", + ("image",), + (), + "Adobe Trustmark variant P", + "2025-02-05T17:00:00.000Z", + (), + False, + ), + ( + 14, + "com.imatag.lamark.v1", + "watermark", + ("image",), + (), + "Imatag invisible watermarking for images", + "2025-02-26T15:00:00.000Z", + (), + False, + ), + ( + 15, + "com.nagra.nexguard.watermark.1", + "watermark", + ("video", "audio", "image"), + (), + "NAGRA NexGuard Watermarking with Single Frame Detection Capability", + "2025-02-26T13:53:25.000Z", + (), + False, + ), + ( + 16, + "com.mentaport.watermark.1", + "watermark", + ("video", "audio", "image"), + (), + "Mentaport Watermarking Engine", + "2025-03-12T13:00:00.000Z", + (), + False, + ), + ( + 17, + "com.digicaps.fingerprint.1", + "fingerprint", + ("image",), + (), + "DigiCAP Fingerprint Engine", + "2025-06-16T17:00:00.000Z", + (), + False, + ), + ( + 18, + "com.sonicorigin.watermark.1", + "watermark", + ("audio", "video"), + (), + "SonicOrigin Watermarking Engine", + "2025-08-13T18:25:00.000Z", + (), + False, + ), + ( + 19, + "com.microsoft.invismark.1", + "watermark", + ("image", "video"), + (), + "Microsoft Responsible AI InvisMark", + "2025-10-12T18:25:00.000Z", + (), + False, + ), + ( + 20, + "com.microsoft.wavmark.1", + "watermark", + ("audio", "video"), + (), + "Microsoft Responsible AI WavMark", + "2025-10-12T15:00:00.000Z", + (), + False, + ), + ( + 21, + "ai.contentlens.image.basewmk", + "watermark", + ("image",), + (), + "ContentLens Image Watermarking (Base)", + "2025-11-26T14:30:00.000Z", + (), + False, + ), + ( + 22, + "ai.contentlens.image.ensemblewmk", + "watermark", + ("image",), + (), + "ContentLens Image Watermarking (Ensemble)", + "2025-11-26T14:30:00.000Z", + (), + False, + ), + ( + 23, + "ai.contentlens.image.customwmk", + "watermark", + ("image",), + (), + "ContentLens Image Watermarking (Custom)", + "2025-11-26T14:30:00.000Z", + (), + False, + ), + ( + 24, + "ai.contentlens.audio.mono", + "watermark", + ("audio",), + (), + "ContentLens Audio Watermarking (Mono)", + "2025-11-26T14:30:00.000Z", + (), + False, + ), + ( + 25, + "ai.contentlens.audio.stereo", + "watermark", + ("audio",), + (), + "ContentLens Audio Watermarking (Stereo)", + "2025-11-26T14:30:00.000Z", + (), + False, + ), + ( + 26, + "es.lumatrace", + "watermark", + ("image",), + (), + "Java-native Adaptive Spatial Watermarking using Blue-channel injection", + "2026-01-26T17:30:00.000Z", + (), + False, + ), + ( + 27, + "com.markany.watermark.1", + "watermark", + ("image", "video", "audio"), + (), + "MarkAny invisible watermarking with robustness against cropping, resizing, and compression", + "2026-02-25T00:00:00.000Z", + (), + False, + ), + ( + 28, + "com.verimatrix.watermark.1", + "watermark", + ("video",), + (), + ( + "Verimatrix watermarking algorithm: provides an essence-based forensic watermarking " + "mechanism that may be used as a C2PA soft-binding technology" + ), + "2026-02-26T14:16:00.000Z", + (), + False, + ), + ( + 29, + "com.writerslogic.zwc-watermark.1", + "watermark", + (), + ("text/plain", "text/markdown", "text/html"), + "WritersLogic CPOP zero-width character (ZWC) watermark for text content", + "2026-03-18T20:00:00.000Z", + (), + False, + ), + ( + 30, + "com.aiwatermark.videoseal.1", + "watermark", + ("video",), + ("video/mp4", "video/webm"), + ( + "Neural video watermarking using Meta FAIR's VideoSeal model, providing perceptually " + "invisible soft-binding watermarks in video content" + ), + "2026-03-08T00:00:00.000Z", + ("https://aiwatermark.com/api/v1",), + False, + ), + ( + 31, + "com.aiwatermark.pixelseal.1", + "watermark", + ("image",), + ("image/png", "image/jpeg", "image/webp"), + ( + "Neural image watermarking using Meta FAIR's PixelSeal model, providing perceptually " + "invisible soft-binding watermarks in image content" + ), + "2026-03-08T00:00:00.000Z", + ("https://aiwatermark.com/api/v1",), + False, + ), + ( + 32, + "com.aiwatermark.audioseal.1", + "watermark", + ("audio",), + ("audio/mpeg", "audio/wav", "audio/flac", "audio/mp4"), + ( + "Neural audio watermarking using Meta FAIR's AudioSeal model, providing imperceptible " + "soft-binding watermarks in audio content" + ), + "2026-03-08T00:00:00.000Z", + ("https://aiwatermark.com/api/v1",), + False, + ), + ( + 33, + "eu.sasha.softbinding.v1", + "watermark", + ("image",), + ("image/jpeg", "image/png"), + ( + "Invisible watermarking technology for image and video assets, enabling C2PA manifest " + "recovery and content provenance verification after metadata stripping or transcoding" + ), + "2026-04-16T00:00:00.000Z", + (), + False, + ), + ( + 34, + "ai.verda.watermark.image.1", + "watermark", + ("image",), + ("image/png", "image/jpeg", "image/avif"), + "VerdaAI invisible image watermarking", + "2026-04-16T00:00:00.000Z", + (), + False, + ), + ( + 35, + "ai.verda.watermark.video.1", + "watermark", + ("video",), + ("video/mp4", "video/webm", "video/quicktime"), + "VerdaAI invisible video watermarking", + "2026-04-16T00:00:00.000Z", + (), + False, + ), + ( + 36, + "ai.verda.watermark.audio.1", + "watermark", + ("audio",), + ("audio/mpeg", "audio/wav", "audio/aac", "audio/ogg", "audio/flac"), + "VerdaAI invisible audio watermarking", + "2026-04-16T00:00:00.000Z", + (), + False, + ), + ( + 37, + "com.evixar.eaw.1", + "watermark", + ("audio", "video"), + (), + "EAW (Evixar Audio Watermark) is an audible-range audio watermarking algorithm developed by Evixar Inc", + "2026-04-24T00:00:00.000Z", + (), + False, + ), + ( + 38, + "com.evixar.efp.type3.1", + "fingerprint", + ("audio", "video"), + (), + "EFP Type 3 (Evixar Finger Print Type 3) is an audio fingerprinting algorithm developed by Evixar Inc", + "2026-04-24T00:00:00.000Z", + (), + False, + ), + ( + 39, + "com.cognitive-proof.vsrmark.1", + "watermark", + ("text",), + (), + ( + "VSRMark hides a structured byte payload inside visible Unicode text by appending " + "invisible Variation Selectors after visible characters" + ), + "2026-06-04T00:00:00.000Z", + (), + False, + ), + ( + 40, + "me.deepmark.audio.vigil.128", + "watermark", + ("audio",), + (), + "Vigil is a 128-bit-per-second neural audio watermarking algorithm developed by DeepMark Inc", + "2026-06-05T00:00:00Z", + ("https://resolution-api.deepmark.me",), + False, + ), + ( + 41, + "com.writerslogic.text-fingerprint.1", + "fingerprint", + (), + ("text/plain", "text/markdown", "text/html"), + "WritersLogic CPoE robust text fingerprint", + "2026-06-22T00:00:00.000Z", + (), + False, + ), + ( + 42, + "com.writerslogic.zwc-watermark.2", + "watermark", + (), + ("text/plain", "text/markdown", "text/html"), + "Revision of com.writerslogic.zwc-watermark.1", + "2026-07-07T00:00:00.000Z", + (), + False, + ), + ( + 43, + "com.writerslogic.text-structure.1", + "fingerprint", + (), + ("text/plain", "text/markdown", "text/html"), + "WritersLogic CPoE structural text fingerprint", + "2026-07-07T00:00:00.000Z", + (), + False, + ), + ( + 44, + "com.writerslogic.text-minhash.1", + "fingerprint", + (), + ("text/plain", "text/markdown", "text/html"), + "WritersLogic CPoE lexical MinHash text fingerprint for excerpt and quotation matching", + "2026-07-07T00:00:00.000Z", + (), + False, + ), + ( + 45, + "io.blockfact.audio.watermark.32", + "watermark", + ("audio", "image", "video"), + (), + "BlockFact invisible watermarking for audio, image, and video provenance", + "2026-07-10T16:00:00.000Z", + ("https://api.blockfact.io/api/soft-binding/v1",), + False, + ), + ( + 46, + "com.doverunner.watermark.1", + "watermark", + ("video",), + (), + ( + "DoveRunner's forensic watermarking solution delivers invisible, tamper-resilient " + "traceability into video content — from enterprise distribution pipelines to " + "end-consumer playback" + ), + "2026-07-27T14:00:00.000Z", + (), + False, + ), + ( + 47, + "com.writerslogic.text-watermark-semantic.1", + "watermark", + (), + ("text/plain", "text/markdown", "text/html"), + ( + "WritersLogic CPoE post-hoc semantic embedding watermark for paraphrase- and " + "translation-robust manuscript identification" + ), + "2026-07-28T00:00:00.000Z", + (), + False, + ), + ( + 48, + "com.joinmonolith.sha256", + "fingerprint", + ("image", "video", "audio", "text", "application"), + (), + "Monolith SHA-256 content fingerprint for C2PA manifest recovery", + "2026-07-29T00:00:00.000Z", + ("https://api.joinmonolith.com/api/c2pa",), + False, + ), + ( + 49, + "com.museblossom.contentsdefence.1", + "watermark", + ("image",), + (), + ( + "Contents Defence invisible image watermark, used to recover the associated C2PA " + "manifest when embedded metadata is absent" + ), + "2026-08-10T00:00:00.000Z", + (), + False, + ), + ( + 50, + "me.reconize.videoseal.1", + "watermark", + ("image", "video"), + (), + ( + "Reconize deployment of the VideoSeal invisible watermark (Meta AI open research), " + "embedding a 256-bit identity payload into image and video pixels via on-device Core ML" + ), + "2026-08-10T00:00:00.000Z", + (), + False, + ), + ( + 51, + "io.diker.sigi.v1", + "watermark", + ("image",), + (), + "Sigi invisible watermarking engine by Okeanos B.V", + "2026-08-11T00:00:00.000Z", + (), + False, + ), + ( + 52, + "com.adobe.hiermark.A", + "watermark", + ("audio",), + (), + "Adobe Hierarchical Audio watermark (HierMark) variant A", + "2026-08-13T12:00:00.000Z", + (), + False, + ), + ( + 53, + "com.adobe.flowmark.A", + "watermark", + ("video",), + (), + "Adobe FlowMark video watermarking variant A", + "2026-08-13T12:00:00.000Z", + (), + False, + ), +) diff --git a/src/remove_ai_watermarks/_internal/c2pa.py b/src/remove_ai_watermarks/_internal/c2pa.py index b933b3a..52708de 100644 --- a/src/remove_ai_watermarks/_internal/c2pa.py +++ b/src/remove_ai_watermarks/_internal/c2pa.py @@ -20,13 +20,16 @@ from remove_ai_watermarks._internal.constants import ( C2PA_CHUNK_TYPE, C2PA_ISSUERS, C2PA_SIGNATURES, + C2PA_SOFT_BINDING_REGISTRY, C2PA_SOFT_BINDINGS, PNG_SIGNATURE, + C2paSoftBindingAlgorithm, ) logger = logging.getLogger(__name__) if TYPE_CHECKING: + from collections.abc import Iterable from typing import BinaryIO _C2paReader: Any = None @@ -536,12 +539,18 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: has_watermark_action = any(action.startswith("watermarked") for action in actions) if has_watermark_action: info["watermarked"] = True - if info.get("ai_source_kind") and not soft_binding_algorithms: + soft_binding_algorithms = list(dict.fromkeys(soft_binding_algorithms)) + algorithms = "\n".join(soft_binding_algorithms).encode() + soft_binding_entries = soft_binding_registry_entries_in(algorithms) + soft_binding_blocks_synthid = any(entry.kind == "watermark" for entry in soft_binding_entries) or len( + soft_binding_entries + ) < len(soft_binding_algorithms) + if info.get("ai_source_kind") and not soft_binding_blocks_synthid: # Evidence scope: only the signer/generator identity strings above, never # the whole chain - a vendor named inside another vendor's manifest (the # Designer case) must not turn into that vendor's SynthID provenance. A - # manifest that names its own forensic soft-binding algorithm carries - # that vendor's mark and is excluded from the generic inference entirely. + # registered fingerprint does not suppress an independently established + # SynthID watermark; a watermark or unknown algorithm does. identity_bytes = json.dumps(identity_strings, ensure_ascii=False).encode() synthid = synthid_evidence_vendors_in(identity_bytes, has_watermark_action=has_watermark_action) if synthid: @@ -549,9 +558,7 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: info["synthid_watermark"] = synthid_verdict(", ".join(synthid)) if soft_binding_algorithms: - soft_binding_algorithms = list(dict.fromkeys(soft_binding_algorithms)) - algorithms = "\n".join(soft_binding_algorithms).encode() - soft_bindings = soft_binding_vendors_in(algorithms) + soft_bindings = soft_binding_labels(soft_binding_entries) if soft_bindings: info["soft_binding_vendors"] = soft_bindings info["soft_binding"] = ", ".join(soft_bindings) @@ -566,10 +573,6 @@ def synthid_verdict(vendors: str) -> str: return f"present according to {vendors} provenance" -def _names_present(buffer: bytes, registry: dict[bytes, str]) -> list[str]: - return sorted({label for token, label in registry.items() if token in buffer}) - - def synthid_evidence_vendors_in(buffer: bytes, *, has_watermark_action: bool | None = None) -> list[str]: """List issuers whose provenance establishes SynthID for this asset. @@ -594,7 +597,26 @@ def synthid_evidence_vendors_in(buffer: bytes, *, has_watermark_action: bool | N def soft_binding_vendors_in(buffer: bytes) -> list[str]: """List the soft-binding algorithms named in manifest bytes.""" - return _names_present(buffer, C2PA_SOFT_BINDINGS) + return soft_binding_labels(soft_binding_registry_entries_in(buffer)) + + +def soft_binding_registry_entries_in(buffer: bytes) -> tuple[C2paSoftBindingAlgorithm, ...]: + """Return exact registered soft-binding entries named in manifest bytes.""" + return tuple( + entry + for entry in C2PA_SOFT_BINDING_REGISTRY + if re.search( + rb"(? list[str]: + """Return stable display labels for matching registry entries.""" + return sorted( + {C2PA_SOFT_BINDINGS[entry.algorithm.encode()] for entry in entries if kind is None or entry.kind == kind} + ) def _ordered_matches(buffer: bytes, registry: dict[bytes, str]) -> list[str]: @@ -624,10 +646,11 @@ def _populate_registry_fields(buffer: bytes, info: dict[str, Any]) -> bool: if b"c2pa.watermarked" in buffer: info["watermarked"] = True - soft_bindings = soft_binding_vendors_in(buffer) + soft_binding_entries = soft_binding_registry_entries_in(buffer) + soft_bindings = soft_binding_labels(soft_binding_entries) synthid = ( [] - if soft_bindings + if any(entry.kind == "watermark" for entry in soft_binding_entries) else synthid_evidence_vendors_in(buffer, has_watermark_action=info.get("watermarked", False)) ) if ai_source and synthid: diff --git a/src/remove_ai_watermarks/_internal/constants.py b/src/remove_ai_watermarks/_internal/constants.py index 1cd7a65..6aac949 100644 --- a/src/remove_ai_watermarks/_internal/constants.py +++ b/src/remove_ai_watermarks/_internal/constants.py @@ -4,6 +4,8 @@ from __future__ import annotations from dataclasses import dataclass +from remove_ai_watermarks._internal._generated_c2pa_soft_bindings import C2PA_SOFT_BINDING_ROWS + def _tokens(value: str) -> tuple[str, ...]: return tuple(value.split("|")) @@ -139,7 +141,26 @@ C2PA_AI_TOOLS = { ) } -C2PA_SOFT_BINDINGS = { + +@dataclass(frozen=True, slots=True) +class C2paSoftBindingAlgorithm: + """One normalized entry from the official C2PA soft-binding registry.""" + + identifier: int + algorithm: str + kind: str + decoded_media_types: tuple[str, ...] + encoded_media_types: tuple[str, ...] + display_label: str + date_entered: str + resolution_apis: tuple[str, ...] + deprecated: bool + + +# These compact historical labels are the public display contract. The generated +# snapshot supplies every exact registered algorithm and its official-description +# fallback, while these prefixes keep existing output concise and stable. +_C2PA_SOFT_BINDING_LABEL_OVERRIDES = { b"com.adobe.trustmark": "Adobe TrustMark", b"com.adobe.icn": "Adobe Image Comparator Network", b"com.digimarc": "Digimarc Validate", @@ -162,6 +183,19 @@ C2PA_SOFT_BINDINGS = { b"io.iscc": "ISCC (content code)", } +C2PA_SOFT_BINDING_REGISTRY = tuple(C2paSoftBindingAlgorithm(*row) for row in C2PA_SOFT_BINDING_ROWS) + + +def _c2pa_soft_binding_label(entry: C2paSoftBindingAlgorithm) -> str: + encoded_algorithm = entry.algorithm.encode() + return next( + (label for prefix, label in _C2PA_SOFT_BINDING_LABEL_OVERRIDES.items() if encoded_algorithm.startswith(prefix)), + entry.display_label, + ) + + +C2PA_SOFT_BINDINGS = {entry.algorithm.encode(): _c2pa_soft_binding_label(entry) for entry in C2PA_SOFT_BINDING_REGISTRY} + AI_GENERATOR_TOKENS = frozenset( { "firefly", diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 6198f4e..ac3d5c9 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -33,7 +33,8 @@ from remove_ai_watermarks._internal.c2pa import ( c2pa_info_has_removal_hint, cbor_text_after, extract_c2pa_info, - soft_binding_vendors_in, + soft_binding_labels, + soft_binding_registry_entries_in, synthid_evidence_vendors_in, synthid_verdict, ) @@ -157,8 +158,8 @@ _C2PA_CLOUD_CAVEAT = ( "human edit, and reading it needs a network fetch this tool does not make." ) _SOFT_BINDING_CAVEAT = ( - "Removing the embedded C2PA manifest does not remove its soft binding: the named " - "watermark or fingerprint may remain in the pixels and re-link the asset to provenance." + "Removing the embedded C2PA manifest does not break its soft binding: a named watermark may remain in the pixels " + "or other media, while a content fingerprint may still be recomputed and re-link the asset to provenance." ) _SAMSUNG_GENAI_CAVEAT = ( "Samsung's genAIType marker shows a Galaxy AI editing tool (Generative Edit, " @@ -1274,16 +1275,27 @@ def _identify_from_evidence( # the file path's answer must not move. trained_source = b"trainedAlgorithmicMedia" in head or b"TrainedAlgorithmicMedia" in head # Same suppression as every other inference site: bytes that name their own - # forensic soft-binding algorithm carry that vendor's mark, and the generic - # vendor-token inference must not add a second, differently-attributed - # invisible watermark (Microsoft Designer: "Azure OpenAI ImageGen" agent + - # the InvisMark watermarked action read as "SynthID per OpenAI"). - soft_binding_vendors = soft_binding_vendors_in(region) + # watermark soft-binding carries that vendor's mark, and the generic + # vendor-token inference must not add a second, differently-attributed mark + # (Microsoft Designer: "Azure OpenAI ImageGen" agent + the InvisMark action + # read as "SynthID per OpenAI"). Fingerprints do not suppress independent + # SynthID evidence; an unknown algorithm stays fail-safe as a possible mark. + soft_binding_algorithm = meta.get("soft_binding_algorithm") or info.get("soft_binding_algorithm") + soft_binding_scan = region + if soft_binding_algorithm: + soft_binding_scan += b"\n" + str(soft_binding_algorithm).encode("utf-8", "replace") + soft_binding_entries = soft_binding_registry_entries_in(soft_binding_scan) + soft_binding_vendors = soft_binding_labels(soft_binding_entries) + soft_binding_watermarks = soft_binding_labels(soft_binding_entries, kind="watermark") + soft_binding_fingerprints = soft_binding_labels(soft_binding_entries, kind="fingerprint") + soft_binding_blocks_synthid = bool(soft_binding_watermarks) or bool( + soft_binding_algorithm and not soft_binding_entries + ) if ( not synthid and trained_source and c2pa_marker_in(head) - and not soft_binding_vendors + and not soft_binding_blocks_synthid and (vendors := synthid_evidence_vendors_in(region)) ): synthid = synthid_verdict(", ".join(vendors)) @@ -1297,20 +1309,23 @@ def _identify_from_evidence( if c2pa_usable and (v := _vendor_of(synthid)): ai_vendor_claims["synthid"] = v - # ── C2PA soft-binding: a named forensic/third-party watermark vendor ─ - # (Adobe TrustMark, Digimarc, Imatag, ...). Present in the manifest even when - # the watermark itself can't be decoded; names whose watermark stamped the pixels. + # ── C2PA soft-binding: a registered watermark or content fingerprint ─ + # Present in the manifest even when the binding cannot be resolved locally. soft_binding = meta.get("soft_binding") or (", ".join(soft_binding_vendors) if soft_binding_vendors else None) if soft_binding: - soft_binding_algorithm = meta.get("soft_binding_algorithm") or info.get("soft_binding_algorithm") soft_binding_value = meta.get("soft_binding_value") or info.get("soft_binding_value") soft_binding_details = "; ".join( str(value) for value in (soft_binding, soft_binding_algorithm, soft_binding_value) if value ) + signal_prefix = ( + "C2PA content fingerprint" + if soft_binding_fingerprints and not soft_binding_watermarks + else "C2PA soft binding" + ) signals.append( Signal( "soft_binding", - f"C2PA soft binding: {soft_binding_details}", + f"{signal_prefix}: {soft_binding_details}", "high" if c2pa_level == "verified" else "medium", ) ) @@ -1325,7 +1340,11 @@ def _identify_from_evidence( "high" if c2pa_level == "verified" else "medium", ) ) - watermarks.append(f"Forensic watermark soft binding ({soft_binding_details})") + if soft_binding_watermarks: + watermark_details = ( + soft_binding_details if not soft_binding_fingerprints else ", ".join(soft_binding_watermarks) + ) + watermarks.append(f"Forensic watermark soft binding ({watermark_details})") caveats.append(_SOFT_BINDING_CAVEAT) # ── IPTC "Made with AI" (Meta etc.), only meaningful without C2PA ─ diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 78914f2..8d657bb 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -840,12 +840,11 @@ def synthid_source(image_path: Path, *, c2pa_info: dict[str, Any] | None = None) ai_source = b"trainedAlgorithmicMedia" in data or b"TrainedAlgorithmicMedia" in data if not (has_c2pa and ai_source): return None - from remove_ai_watermarks._internal.c2pa import soft_binding_vendors_in + from remove_ai_watermarks._internal.c2pa import soft_binding_registry_entries_in - # A scan that names its own forensic soft-binding algorithm carries that - # vendor's mark; the generic vendor-token inference must not add a second, - # differently-attributed invisible watermark from the same bytes. - if soft_binding_vendors_in(data): + # A declared watermark blocks a second, generic watermark attribution. A + # content fingerprint does not suppress independent SynthID evidence. + if any(entry.kind == "watermark" for entry in soft_binding_registry_entries_in(data)): return None matched = synthid_evidence_vendors_in(data) return ", ".join(matched) if matched else None @@ -1184,7 +1183,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]: # Non-PNG containers (JPEG/WebP/AVIF/MP4): extract_c2pa_info is PNG-only, so # fall back to the format-agnostic source check for the SynthID verdict and - # the soft-binding (forensic-watermark vendor) scan. + # the registered soft-binding display scan. if "synthid_watermark" not in result and (vendor := synthid_source(image_path)): result.setdefault("synthid_watermark", synthid_verdict(vendor)) if "soft_binding" not in result: diff --git a/tests/test_c2pa_registry_sync.py b/tests/test_c2pa_registry_sync.py new file mode 100644 index 0000000..7428c1e --- /dev/null +++ b/tests/test_c2pa_registry_sync.py @@ -0,0 +1,80 @@ +"""Guards for the generated official C2PA soft-binding registry snapshot.""" + +from __future__ import annotations + +import re + +import pytest + +import scripts.sync_c2pa_soft_bindings as sync +from remove_ai_watermarks._internal._generated_c2pa_soft_bindings import ( + C2PA_SOFT_BINDING_ROWS, + C2PA_SOFT_BINDING_SOURCE_LICENSE, + C2PA_SOFT_BINDING_SOURCE_REVISION, + C2PA_SOFT_BINDING_SOURCE_URL, +) +from remove_ai_watermarks._internal.constants import C2PA_SOFT_BINDING_REGISTRY, C2PA_SOFT_BINDINGS +from scripts.sync_c2pa_soft_bindings import parse_registry, render_module + + +def _entry(*, identifier: int = 1, algorithm: str = "com.example.mark.1") -> dict[str, object]: + return { + "identifier": identifier, + "alg": algorithm, + "type": "watermark", + "encodedMediaTypes": ["text/plain"], + "entryMetadata": { + "description": "Example watermark. Longer implementation detail.", + "dateEntered": "2026-01-01T00:00:00Z", + "contact": "registry@example.com", + "informationalUrl": "https://example.com/mark", + }, + } + + +def test_packaged_registry_covers_the_current_official_baseline(): + algorithms = [entry.algorithm for entry in C2PA_SOFT_BINDING_REGISTRY] + identifiers = [entry.identifier for entry in C2PA_SOFT_BINDING_REGISTRY] + + assert len(C2PA_SOFT_BINDING_REGISTRY) >= 53 + assert identifiers == sorted(identifiers) + assert len(algorithms) == len(set(algorithms)) + assert set(C2PA_SOFT_BINDINGS) == {algorithm.encode() for algorithm in algorithms} + assert sum(entry.date_entered.startswith("2026-") for entry in C2PA_SOFT_BINDING_REGISTRY) >= 28 + assert {entry.kind for entry in C2PA_SOFT_BINDING_REGISTRY} == {"watermark", "fingerprint"} + assert "com.adobe.hiermark.A" in algorithms + assert "com.adobe.flowmark.A" in algorithms + + assert C2PA_SOFT_BINDING_ROWS + assert C2PA_SOFT_BINDING_SOURCE_URL.endswith("/softbinding-algorithm-list.json") + assert re.fullmatch(r"[0-9a-f]{40}", C2PA_SOFT_BINDING_SOURCE_REVISION) + assert C2PA_SOFT_BINDING_SOURCE_LICENSE == "CC BY 4.0" + + +def test_sync_accepts_encoded_media_and_renders_attribution(): + rows = parse_registry([_entry()]) + + assert rows[0].encoded_media_types == ("text/plain",) + assert rows[0].decoded_media_types == () + assert rows[0].display_label == "Example watermark" + rendered = render_module(rows, source="fixture.json", revision="fixture-revision") + assert "Source license: CC BY 4.0" in rendered + assert "Changes: validated, ordered" in rendered + + +def test_sync_rejects_duplicate_algorithms(): + with pytest.raises(ValueError, match="algorithms must be unique"): + parse_registry([_entry(identifier=1), _entry(identifier=2)]) + + +def test_official_source_is_fetched_through_its_resolved_revision(monkeypatch: pytest.MonkeyPatch): + revision = "a" * 40 + requested: list[str] = [] + monkeypatch.setattr(sync, "_latest_revision", lambda: revision) + monkeypatch.setattr(sync, "_read_bytes", lambda source: requested.append(source) or b"[]") + + payload, resolved = sync._source_snapshot(sync.DEFAULT_SOURCE_URL, None) + + assert payload == b"[]" + assert resolved == revision + assert requested == [sync.PINNED_SOURCE_URL.format(revision=revision)] diff --git a/tests/test_designer_synthid_suppression.py b/tests/test_designer_synthid_suppression.py index f1f7b22..2191535 100644 --- a/tests/test_designer_synthid_suppression.py +++ b/tests/test_designer_synthid_suppression.py @@ -1,4 +1,4 @@ -"""Regression: a manifest that names its own forensic soft binding must not +"""Regression: a manifest that names its own watermark soft binding must not also report a SynthID watermark from the generic vendor-token inference. Microsoft Designer manifests sign as Microsoft, carry the InvisMark @@ -45,7 +45,7 @@ DESIGNER_STORE = { } -def test_named_soft_binding_suppresses_generic_synthid_evidence() -> None: +def test_named_watermark_soft_binding_suppresses_generic_synthid_evidence() -> None: info = c2pa_info_from_manifest_store(DESIGNER_STORE) assert info["ai_source_kind"] == "generated" assert info["soft_binding_algorithm"] == "com.microsoft.invismark.1" @@ -53,6 +53,39 @@ def test_named_soft_binding_suppresses_generic_synthid_evidence() -> None: assert info.get("synthid_vendors") is None +def test_content_fingerprint_does_not_suppress_google_synthid_evidence() -> None: + store = { + "active_manifest": "google", + "manifests": { + "google": { + "signature_info": {"issuer": "Google LLC"}, + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": ( + "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + ), + } + ] + }, + }, + {"label": "c2pa.soft-binding", "data": {"alg": "io.iscc.v0"}}, + ], + } + }, + } + + info = c2pa_info_from_manifest_store(store) + + assert info["soft_binding"] == "ISCC (content code)" + assert info["synthid_vendors"] == ["Google LLC"] + assert info["synthid_watermark"] == "present according to Google LLC provenance" + + def test_vendor_agent_name_alone_is_not_the_vendors_provenance() -> None: """The identity-scoped inference must not fire on a service name either. diff --git a/tests/test_identify.py b/tests/test_identify.py index e1156b0..f17e73e 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -1280,7 +1280,7 @@ class TestIdentifyXaiSignature: class TestIdentifySoftBinding: - """A C2PA soft-binding alg names a forensic-watermark vendor in the inventory.""" + """C2PA soft-binding algorithms retain their registered kind in the report.""" def test_soft_binding_vendor_listed(self, tmp_path: Path): p = tmp_path / "sb.jpg" @@ -1289,6 +1289,17 @@ class TestIdentifySoftBinding: assert any("Digimarc" in w for w in r.watermarks) assert any(s.name == "soft_binding" for s in r.signals) + def test_fingerprint_is_not_listed_as_a_watermark(self, tmp_path: Path): + p = tmp_path / "fingerprint.jpg" + p.write_bytes(b"\xff\xd8\xff\xe1 c2pa jumb io.iscc.v0 \xff\xd9") + + report = identify(p, check_visible=False, check_invisible=False) + + signal = next(signal for signal in report.signals if signal.name == "soft_binding") + assert signal.detail.startswith("C2PA content fingerprint: ISCC (content code)") + assert not any("soft binding" in watermark.casefold() for watermark in report.watermarks) + assert any("fingerprint may still be recomputed" in caveat for caveat in report.caveats) + def test_invismark_signal_lists_signed_watermark_id(self, tmp_path: Path): watermark_id = "83424621-03cb-40e3-9808-a9fae837156d" record = { diff --git a/tests/test_metadata.py b/tests/test_metadata.py index eb391e6..c62b94f 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1615,12 +1615,12 @@ class TestAIGCRealSample: class TestSoftBinding: - """C2PA soft-binding alg identifier -> forensic-watermark vendor name.""" + """C2PA soft-binding alg identifier -> registered display name.""" def test_vendors_in_recognizes_known_algs(self): from remove_ai_watermarks._internal.c2pa import soft_binding_vendors_in - assert soft_binding_vendors_in(b"...alg...com.adobe.trustmark.P...") == ["Adobe TrustMark"] + assert soft_binding_vendors_in(b'...alg:"com.adobe.trustmark.P"...') == ["Adobe TrustMark"] assert soft_binding_vendors_in(b"com.digimarc.validate.1") == ["Digimarc Validate"] assert soft_binding_vendors_in(b"ai.steg.api blah") == ["Steg.AI"] # Registry-verified vendors added in v0.6.x. @@ -1629,11 +1629,16 @@ class TestSoftBinding: assert soft_binding_vendors_in(b"com.aiwatermark.videoseal.1") == ["AIWatermark VideoSeal"] assert soft_binding_vendors_in(b"com.aiwatermark.audioseal.1") == ["AIWatermark AudioSeal"] assert soft_binding_vendors_in(b"io.iscc.v0") == ["ISCC (content code)"] + assert soft_binding_vendors_in(b"com.adobe.hiermark.A") == [ + "Adobe Hierarchical Audio watermark (HierMark) variant A" + ] + assert soft_binding_vendors_in(b"com.adobe.flowmark.A") == ["Adobe FlowMark video watermarking variant A"] def test_vendors_in_empty_when_absent(self): from remove_ai_watermarks._internal.c2pa import soft_binding_vendors_in assert soft_binding_vendors_in(b"no soft binding here") == [] + assert soft_binding_vendors_in(b"com.adobe.trustmark.P2") == [] def test_get_ai_metadata_surfaces_soft_binding(self, tmp_path: Path): # Non-PNG binary-scan path: a manifest naming a soft-binding vendor. @@ -1641,6 +1646,14 @@ class TestSoftBinding: p.write_bytes(b"\xff\xd8\xff\xe1 c2pa jumb com.adobe.trustmark.P \xff\xd9") assert get_ai_metadata(p).get("soft_binding") == "Adobe TrustMark" + def test_fingerprint_does_not_suppress_raw_google_synthid(self, tmp_path: Path): + from remove_ai_watermarks.metadata import synthid_source + + path = tmp_path / "google-fingerprint.jpg" + path.write_bytes(b"\xff\xd8\xff\xe1 c2pa jumb Google trainedAlgorithmicMedia io.iscc.v0 \xff\xd9") + + assert synthid_source(path) == "Google LLC" + class TestIptcAiFields: """IPTC 2025.1 AI-disclosure XMP properties (Iptc4xmpExt:AISystemUsed etc.).""" diff --git a/tests/test_metadata_internals.py b/tests/test_metadata_internals.py index 752053c..f47ef76 100644 --- a/tests/test_metadata_internals.py +++ b/tests/test_metadata_internals.py @@ -779,7 +779,7 @@ class TestC2paBufferScans: from remove_ai_watermarks._internal.c2pa import C2PA_SOFT_BINDINGS, soft_binding_vendors_in sig, name = next(iter(C2PA_SOFT_BINDINGS.items())) - assert name in soft_binding_vendors_in(b"...manifest..." + sig + b"...tail...") + assert name in soft_binding_vendors_in(b'...manifest:"' + sig + b'"...tail...') assert soft_binding_vendors_in(b"") == [] assert soft_binding_vendors_in(b"no soft-binding assertion here") == [] @@ -792,6 +792,15 @@ class TestC2paBufferScans: ] assert synthid_evidence_vendors_in(b"c2pa Google trainedAlgorithmicMedia") == ["Google LLC"] + def test_fingerprint_does_not_suppress_fallback_google_synthid(self): + from remove_ai_watermarks._internal.c2pa import _populate_registry_fields + + info: dict[str, object] = {} + _populate_registry_fields(b"c2pa Google trainedAlgorithmicMedia io.iscc.v0", info) + + assert info["soft_binding"] == "ISCC (content code)" + assert info["synthid_vendors"] == ["Google LLC"] + def test_synthid_verdict_format(self): from remove_ai_watermarks._internal.c2pa import synthid_verdict