From 97cba11efe91fee8261f802e0a837bb221f6a80c Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Tue, 25 Aug 2026 15:54:26 -0700 Subject: [PATCH] Read the InvisMark soft-binding algorithm and its signed watermark id Reachable c2pa.soft-binding assertions now surface their exact alg and the bounded printable block value next to the normalized vendor label; a value without its algorithm stays hidden because it cannot be attributed. com.microsoft.invismark.1 uses that value as the pixel-watermark identifier in Microsoft Paint output, so identify names it, metadata carries it, and an InvisMark soft binding keeps the invisible-removal gate fail-safe even after C2PA asset binding goes invalid. Content fingerprints still do not trigger pixel regeneration. Removal is verified against Microsoft's Content Provenance Detection API, which reports Watermark and C2PA separately; the protocol and the pixel-identical control requirement are documented. Implemented in a parallel session; verified, gated, and committed by pi. pre-commit: 1) maintain.sh - exit 1 on the known uv-secure lightning PYSEC-2026-3624 triage (no fix available, unchanged from 0.29.0); ruff, pyright src/, and 1391 tests passed separately; 2) /simplify - single-pass, clean; 3) docs sync - five docs updated by the author session, no remaining references found; 4) CLAUDE.md - invariants recorded in module-internals, no change needed --- docs/cli.md | 5 +- docs/module-internals.md | 9 ++++ docs/supported-signals.md | 13 +++++ docs/verification-plan.md | 12 +++-- docs/watermarking-landscape.md | 3 +- src/remove_ai_watermarks/_internal/c2pa.py | 20 +++++++- src/remove_ai_watermarks/identify.py | 18 ++++++- src/remove_ai_watermarks/metadata.py | 2 + tests/test_identify.py | 30 +++++++++++ tests/test_metadata_internals.py | 59 ++++++++++++++++++++++ 10 files changed, 161 insertions(+), 10 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 01821d5..11a836e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -43,7 +43,10 @@ is found, it reports the origin as unknown. It does not claim the image is clean. For C2PA files, the text report shows asset integrity, claim-signature, signer-trust, and signer-validity results separately. An intact claim from an untrusted or expired signer is reported at medium confidence; a failed asset -binding or signature does not confirm the claimed origin. +binding or signature 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. Machine readable output: diff --git a/docs/module-internals.md b/docs/module-internals.md index 0966384..439d10b 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -392,6 +392,15 @@ manifest names only `c2pa-tool` while a validated ingredient names Dreamina, and Firefly chains that identify `Adobe_Firefly` without repeating a digital source 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 +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. Content-fingerprint soft bindings do not trigger pixel regeneration. + The SDK default enables trust verification but supplies no production trust anchors. Consequently, an installation without an explicitly maintained C2PA trust bundle reports otherwise valid signer chains as untrusted and keeps their diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 0082b8a..27a8e87 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -138,6 +138,18 @@ all-media watermark policy, and current OpenAI C2PA carrying an explicit assert SynthID. After provenance metadata is removed, a local negative result is still inconclusive. +Microsoft Paint can name `com.microsoft.invismark.1` in a C2PA soft-binding +assertion. Inspection reports both that exact algorithm and its signed `value`, +which Paint uses as the identifier carried by the pixel watermark. Photos has a +parallel local writer path, but no public output sample was available for this +work. Metadata stripping removes the embedded manifest, not the pixel carrier. +The project has no validated local InvisMark decoder. Microsoft's official +[Content Provenance Detection API](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection) +is the external oracle: it reports pixel `Watermark` and embedded `C2PA` results +separately. The diffusion profiles have not been broadly certified against that +oracle, so a control-positive, output-negative pair is still a per-file result, +not a universal guarantee. + For MP4, MOV, and M4V, `video invisible` or the explicit `video all --invisible` option can regenerate the video through a VAE and strip source metadata. The shipped profile is oracle-certified, but it is not a local @@ -158,6 +170,7 @@ not a universal clean verdict. | Google Gemini | Sparkle | Diffusion regeneration for SynthID | C2PA and related source signals | | Google Veo video | Veo diamond and legacy text | Oracle-certified VAE removal for SynthID | C2PA and related source signals | | OpenAI image generators | None registered | Diffusion regeneration for supported invisible signals | C2PA and generator provenance | +| Microsoft Paint and Photos | None registered | External Microsoft oracle for InvisMark; no validated local decoder | Paint C2PA soft-binding algorithm and identifier | | Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata | | FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources | | Adobe Firefly | None registered | Optional TrustMark Variant P decoder | C2PA | diff --git a/docs/verification-plan.md b/docs/verification-plan.md index f249757..fa2113f 100644 --- a/docs/verification-plan.md +++ b/docs/verification-plan.md @@ -186,10 +186,14 @@ report recall from the detector-sampled set. ## Tier D -- external oracles (manual, not automatable here) -SynthID removal cannot be verified locally by design -- no public decoder exists. Each -vendor has its own oracle and it covers only that vendor's content: `openai.com/verify` for -OpenAI (more accessible, the automation candidate), the Gemini app for Google (manual, -rate-limited). A quiet metadata proxy is **not** proof the pixel watermark is gone. +Proprietary watermark removal cannot be verified locally by design -- no public decoder +exists. Each vendor has its own oracle and it covers only that vendor's content: +`openai.com/verify` for OpenAI, the Gemini app for Google, and Microsoft's +[Content Provenance Detection API](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection) +for InvisMark. The Microsoft API reports `Watermark` and `C2PA` separately. It therefore +needs a pixel-identical metadata-stripped control: the control must lose `C2PA` while +remaining `Watermark`-positive before a candidate's negative result can be attributed to +pixel regeneration. A quiet metadata proxy is **not** proof the pixel watermark is gone. Scope honestly: this tier certifies strength floors on a handful of images per vendor, and that is all it can do. See `docs/synthid.md`. diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 572a8a1..eeafcbb 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -65,7 +65,8 @@ 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, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest (Digimarc/Imatag/Steg.AI/...) stay name-only (proprietary decoders). +- **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. +- **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. 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 ISOBMFF video, the optional Adobe TrustMark decoder, and temporally stabilized diff --git a/src/remove_ai_watermarks/_internal/c2pa.py b/src/remove_ai_watermarks/_internal/c2pa.py index 0cb3918..3c6f873 100644 --- a/src/remove_ai_watermarks/_internal/c2pa.py +++ b/src/remove_ai_watermarks/_internal/c2pa.py @@ -307,7 +307,9 @@ def c2pa_info_has_invalid_credential(info: dict[str, Any]) -> bool: def c2pa_info_has_removal_hint(info: dict[str, Any]) -> bool: """Return whether a C2PA AI or watermark claim should keep removal fail-safe.""" - return bool(info.get("ai_source_kind") or info.get("synthid_watermark")) + soft_bindings = info.get("soft_binding_vendors") + has_invismark = isinstance(soft_bindings, list) and "Microsoft InvisMark" in soft_bindings + return bool(info.get("ai_source_kind") or info.get("synthid_watermark") or has_invismark) def _has_suffix(codes: list[str], suffixes: tuple[str, ...]) -> bool: @@ -390,6 +392,7 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: actions: list[str] = [] source_types: list[str] = [] soft_binding_algorithms: list[str] = [] + soft_binding_values: list[str] = [] claim_generator_asserts_ai = False def add_tool_matches(value: str, *, asserts_ai: bool = False) -> None: @@ -431,9 +434,18 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: label = assertion.get("label") data = assertion.get("data") if isinstance(label, str) and label.startswith("c2pa.soft-binding") and isinstance(data, dict): - algorithm = cast("dict[object, object]", data).get("alg") + soft_binding = cast("dict[object, object]", data) + algorithm = soft_binding.get("alg") if isinstance(algorithm, str): soft_binding_algorithms.append(algorithm) + blocks = soft_binding.get("blocks") + if isinstance(blocks, list): + for block_value in cast("list[object]", blocks): + if not isinstance(block_value, dict): + continue + value = cast("dict[object, object]", block_value).get("value") + if isinstance(value, str) and value.isprintable() and len(value) <= 256: + soft_binding_values.append(value) if not (isinstance(label, str) and label.startswith("c2pa.actions") and isinstance(data, dict)): continue action_values = cast("dict[object, object]", data).get("actions") @@ -491,11 +503,15 @@ 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) if soft_bindings: info["soft_binding_vendors"] = soft_bindings info["soft_binding"] = ", ".join(soft_bindings) + info["soft_binding_algorithm"] = ", ".join(soft_binding_algorithms) + if soft_binding_values: + info["soft_binding_value"] = ", ".join(dict.fromkeys(soft_binding_values)) return info diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index ecdeafc..8b2c75f 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -145,6 +145,10 @@ _C2PA_CLOUD_CAVEAT = ( "It marks Content Credentials, not AI origin: the cloud manifest may describe a " "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." +) _SAMSUNG_GENAI_CAVEAT = ( "Samsung's genAIType marker shows a Galaxy AI editing tool (Generative Edit, " "Sketch to Image, ...) touched the image; it is an undocumented proprietary " @@ -388,6 +392,8 @@ def evidence_from_metadata_record( "actions", "synthid_watermark", "soft_binding", + "soft_binding_algorithm", + "soft_binding_value", ): if key in c2pa_info: ai_metadata.setdefault(key, str(c2pa_info[key])) @@ -1269,12 +1275,20 @@ def _identify_from_evidence( # the watermark itself can't be decoded; names whose watermark stamped the pixels. soft_binding = meta.get("soft_binding") or (", ".join(v) if (v := soft_binding_vendors_in(region)) 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 + ) signals.append( Signal( - "soft_binding", f"C2PA soft binding: {soft_binding}", "high" if c2pa_level == "verified" else "medium" + "soft_binding", + f"C2PA soft binding: {soft_binding_details}", + "high" if c2pa_level == "verified" else "medium", ) ) - watermarks.append(f"Forensic watermark soft binding ({soft_binding})") + watermarks.append(f"Forensic watermark soft binding ({soft_binding_details})") + caveats.append(_SOFT_BINDING_CAVEAT) # ── IPTC "Made with AI" (Meta etc.), only meaningful without C2PA ─ iptc = any(m in head for m in IPTC_AI_MARKERS) diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 84286eb..8d8f528 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -1160,6 +1160,8 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]: "actions", "synthid_watermark", "soft_binding", + "soft_binding_algorithm", + "soft_binding_value", "c2pa_validation_source", "c2pa_validation_state", "c2pa_integrity", diff --git a/tests/test_identify.py b/tests/test_identify.py index 706037e..36aa041 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -1144,6 +1144,36 @@ 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_invismark_signal_lists_signed_watermark_id(self, tmp_path: Path): + watermark_id = "83424621-03cb-40e3-9808-a9fae837156d" + record = { + "c2pa_store": { + "active_manifest": "paint", + "manifests": { + "paint": { + "assertions": [ + { + "label": "c2pa.soft-binding", + "data": { + "alg": "com.microsoft.invismark.1", + "blocks": [{"scope": "the entire image", "value": watermark_id}], + }, + } + ] + } + }, + } + } + evidence = evidence_from_metadata_record(record, path=tmp_path / "paint.png") + + report = identify_from_evidence(evidence) + + assert evidence.ai_metadata["soft_binding_value"] == watermark_id + signal = next(signal for signal in report.signals if signal.name == "soft_binding") + assert "com.microsoft.invismark.1" in signal.detail + assert watermark_id in signal.detail + assert any("may remain in the pixels" in caveat for caveat in report.caveats) + class TestIdentifyIptcAi: """IPTC 2025.1 AISystemUsed drives an AI verdict + platform attribution.""" diff --git a/tests/test_metadata_internals.py b/tests/test_metadata_internals.py index 5a4e6ef..7f0120a 100644 --- a/tests/test_metadata_internals.py +++ b/tests/test_metadata_internals.py @@ -13,6 +13,7 @@ from PIL import Image from remove_ai_watermarks._internal.c2pa import ( _parse_c2pa_chunk, c2pa_info_from_manifest_store, + c2pa_info_has_removal_hint, cbor_text_after, extract_c2pa_chunk, extract_c2pa_info, @@ -209,6 +210,64 @@ class TestC2PA: assert info["ai_tool"] == "Dreamina" assert info["c2pa_identity_ai"] is True + def test_structured_invismark_exposes_algorithm_and_watermark_id(self): + watermark_id = "83424621-03cb-40e3-9808-a9fae837156d" + store = { + "active_manifest": "paint", + "manifests": { + "paint": { + "assertions": [ + { + "label": "c2pa.soft-binding", + "data": { + "alg": "com.microsoft.invismark.1", + "blocks": [ + { + "scope": "the entire image", + "value": watermark_id, + } + ], + }, + } + ] + } + }, + } + + info = c2pa_info_from_manifest_store(store) + + assert info["soft_binding"] == "Microsoft InvisMark" + assert info["soft_binding_algorithm"] == "com.microsoft.invismark.1" + assert info["soft_binding_value"] == watermark_id + + def test_soft_binding_value_requires_its_algorithm(self): + store = { + "active_manifest": "broken", + "manifests": { + "broken": { + "assertions": [ + { + "label": "c2pa.soft-binding", + "data": {"blocks": [{"value": "not-attributable"}]}, + } + ] + } + }, + } + + assert "soft_binding_value" not in c2pa_info_from_manifest_store(store) + + def test_soft_binding_keeps_invisible_removal_fail_safe(self): + assert c2pa_info_has_removal_hint({"soft_binding_vendors": ["Microsoft InvisMark"]}) is True + + def test_content_fingerprint_does_not_trigger_invisible_removal(self): + info = { + "soft_binding": "Adobe (content fingerprint)", + "soft_binding_vendors": ["Adobe (content fingerprint)"], + } + + assert c2pa_info_has_removal_hint(info) is False + def test_invalid_ingredient_does_not_taint_active_validation_or_supply_claims(self): store = { "active_manifest": "update",