From 304bd071c0bfa71fe2ea4f6ccaeaae0d0af2bc7d Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Thu, 1 Oct 2026 11:30:32 -0700 Subject: [PATCH] Unify TC260 producers and strengthen provenance regression coverage Share producer identities across mark routing, attribution, and pinned signers. Preserve nested Honor TC260 fields and report unsupported RSA signatures. Document the Google Photos checks and public sample search. Use an SDK-signed synthetic carrier for negative EKU checks after the original Microsoft certificates expired. Skip boundary regex scans when no C2PA soft-binding identifier is present. --- data/fixtures/README.md | 25 ++++ data/fixtures/synthetic/honor-yoyo-tc260.json | 53 +++++++ docs/module-internals.md | 82 +++++++++-- docs/research-sweep-2026-09.md | 135 ++++++++++++++++-- docs/supported-signals.md | 15 +- docs/synthid.md | 14 +- docs/watermarking-landscape.md | 10 +- src/remove_ai_watermarks/_internal/c2pa.py | 5 +- .../_internal/constants.py | 2 + .../_internal/tc260_producers.py | 85 +++++++++++ .../_internal/tc260_signature.py | 38 +++-- src/remove_ai_watermarks/api.py | 6 +- src/remove_ai_watermarks/identify.py | 27 ++-- src/remove_ai_watermarks/metadata.py | 30 ++-- src/remove_ai_watermarks/video.py | 37 ++--- src/remove_ai_watermarks/video_visible.py | 26 +--- .../watermark_registry.py | 26 +--- tests/test_c2pa_registry_sync.py | 10 ++ tests/test_identify.py | 13 +- tests/test_metadata_audit_regressions.py | 78 +++++++++- tests/test_metadata_internals.py | 20 ++- tests/test_platform.py | 2 +- tests/test_sm2.py | 23 ++- tests/test_tc260_producers.py | 51 +++++++ tests/test_web_sample_fixtures.py | 96 +++++++++++++ 25 files changed, 748 insertions(+), 161 deletions(-) create mode 100644 data/fixtures/synthetic/honor-yoyo-tc260.json create mode 100644 src/remove_ai_watermarks/_internal/tc260_producers.py create mode 100644 tests/test_tc260_producers.py create mode 100644 tests/test_web_sample_fixtures.py diff --git a/data/fixtures/README.md b/data/fixtures/README.md index 4ff10cd..810289a 100644 --- a/data/fixtures/README.md +++ b/data/fixtures/README.md @@ -3,6 +3,31 @@ These files exercise real container, metadata, and pixel-provenance formats. They are test inputs, not evaluation corpora. +## Honor YOYO metadata reconstruction + +`synthetic/honor-yoyo-tc260.json` reconstructs the EXIF `UserComment` JSON +structure observed in two public YOYO edits in this +[Honor Club thread](https://club.honor.com/cn/thread-29898091-1-1.html), fetched +on 2026-10-01 UTC. The downloaded JPEGs have SHA-256 +`99aa5cdf42cd4c3849401a32fc1f0738c229cc3ed903eba00a080e7eb29542d5` and +`af779adba954926bb15bb9cb70ff597cb11e715b53e204fce5e64b1edf971aa1`. +They remain untracked because public redistribution has not been cleared. + +The fixture retains the observed producer `001191440300MA5G49LC9K1YO01`, +nested `ReservedCode1`/`ReservedCode2` objects, `PubSd` spelling, null hash +binding, and RSA/SHA-256 algorithm identifiers. Generation IDs, signature, +and public key are explicit synthetic placeholders. It contains no photograph, +account handle, real signature, or claim of a valid asset binding. The producer's +USCC matches Honor Device Co., Ltd. in its +[official privacy statement](https://agreement.itsec.honor.com/asm/agrFile/getHtmlFile?agrNo=1477&branchId=0&country=ru&langCode=en-US&version=20250517). + +`tests/test_web_sample_fixtures.py` is the carrier recipe: it embeds this JSON +in a solid-color JPEG, checks Honor attribution and preservation of nested JSON, +reports the content signature as unsupported, and verifies removal preserves +decoded pixels. This synthetic test input +is distributed under the repository license. It does not establish gallery +control/crop behavior, device/app versions, or a real verified RSA signature. + ## OpenRouter metadata survey The following files are original byte streams returned by OpenRouter's Image diff --git a/data/fixtures/synthetic/honor-yoyo-tc260.json b/data/fixtures/synthetic/honor-yoyo-tc260.json new file mode 100644 index 0000000..e90b43d --- /dev/null +++ b/data/fixtures/synthetic/honor-yoyo-tc260.json @@ -0,0 +1,53 @@ +{ + "AIGC": { + "ContentProducer": "001191440300MA5G49LC9K1YO01", + "ContentPropagator": "001191440300MA5G49LC9K1YO01", + "Label": "1", + "ProduceID": "synthetic-yoyo-fixture", + "PropagateID": "synthetic-yoyo-fixture", + "ReservedCode1": { + "SecurityData": { + "Type": "TC260PG", + "Version": 1, + "Bindings": [ + {"Type": "Hash", "AlgID": "2.16.840.1.101.3.4.2.1", "Value": null} + ], + "PubSd": [ + { + "Type": "DS", + "AlgID": "1.2.840.113549.1.1.11", + "TBSData": {"Type": "Content"}, + "Signature": "synthetic-signature-not-valid" + }, + { + "Type": "PubKey", + "AlgID": "1.2.840.113549.1.1.11", + "KeyValue": "synthetic-public-key-not-valid" + } + ] + } + }, + "ReservedCode2": { + "SecurityData": { + "Type": "TC260PG", + "Version": 1, + "Bindings": [ + {"Type": "Hash", "AlgID": "2.16.840.1.101.3.4.2.1", "Value": null} + ], + "PubSd": [ + { + "Type": "DS", + "AlgID": "1.2.840.113549.1.1.11", + "TBSData": {"Type": "Content"}, + "Signature": "synthetic-signature-not-valid" + }, + { + "Type": "PubKey", + "AlgID": "1.2.840.113549.1.1.11", + "KeyValue": "synthetic-public-key-not-valid" + } + ] + } + } + } +} diff --git a/docs/module-internals.md b/docs/module-internals.md index 61fe47f..ea11432 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -451,12 +451,38 @@ them is geometry over time: the real overlay's box is identical on every frame, texture matches drift, so the Kling policy tightens `anchor_iou` from 0.80 to 0.95. -A video TC260 label names its platform through `_tc260_video_platform`: the -Kling, Doubao, Qwen and Wan registry codes, the Vidu constant, and the bare -`MiniMax`. The labels name the producer organization, because the Tongyi Yunqi -code signs both Wan and HappyHorse. An unregistered producer keeps the generic -"China AIGC-labeled content (TC260 standard)". The image path keeps that generic -platform for every producer and records the manufacturer only for clash checks. +TC260 producer identities live in +[`_internal/tc260_producers.py`](../src/remove_ai_watermarks/_internal/tc260_producers.py). +Each `TC260_PRODUCERS` row records measured bare names/USCCs, the normalized +vendor, image/video mark keys, established image/video platforms, and any pinned +signing identity/key. Whole-code lookup is casefolded and strips surrounding +whitespace; structured `001` producer codes resolve through the same USCC +normalizer, still exported as `metadata.uscc_of`. Substrings cannot identify a +producer. The table imports neither pixel nor ML dependencies. + +`KnownMark.tc260_producer_codes`, visible-video confirmation, video platform +labels, and `TC260_SIGNING_KEYS` are derived from these rows. The signature path +still validates the pinned even-y SM2 point. `identify` uses the registered +vendor for provenance-conflict checks, with a verified pinned signer supplying +the vendor even when the text lookup yields nothing. Verification under an +embedded, unpinned key cannot supply this identity. Unknown producers remain +unattributed and cannot relax a particular mark. Image platforms default to the +generic TC260 label; a measured row can supply a more specific label, currently +Honor. The company USCC does not identify a specific app: YOYO exports and +other product suffixes share it. Vendor attribution is also used for conflict checks. Video +platforms preserve the established organization labels, including Tongyi Yunqi +for both Wan and HappyHorse. + +`tests/test_tc260_producers.py` covers all registered identities across image, +video, and mark routing, normalizes code spellings, and checks every neighboring +producer against the Doubao, Hailuo, and Vidu confirmation gates. +`tests/test_sm2.py` exercises the real signed label with text lookup disabled, +then removes the pinned key and requires vendor attribution to disappear. +The 2026-09-30 migration comparison covered 411 local images and videos with +identical input hashes: C2PA metadata and complete metadata-only `identify` +reports were unchanged. This comparison excludes pixel detection, removal, +GPU paths, and the video identification pipeline; video routing is covered by +the consumer tests. Removal runs in a second decode pass. Sora, legacy Veo text, Dola text, Seedance, Hailuo AI, and Kling AI use box masks. Seedance deliberately fills the @@ -527,9 +553,18 @@ provider original. Their leaf certificates contain the C2PA/document-signing or Microsoft C2PA-signing EKUs that were cleared. Explicit per-reader settings restore the previous acceptance policy; the signers remain `untrusted`. `test_c2pa_signing_ekus_validate_without_trusting_the_signer` exercises all five -through the real reader, then replaces the permitted EKUs with an unrelated OID -and requires credential rejection with intact signature and asset hash. Removing -the context from the reader call makes all five positive cases fail. +through the real reader. The negative EKU check uses a neutral image signed by +the SDK with a local synthetic certificate, fixed validity dates, and no trust +anchors. Replacing permitted EKUs with an unrelated OID must reject that +credential while preserving its signature and asset hash; the AI claim then +loses attribution. Removing the reader context fails the synthetic positive case. + +The Bing and Microsoft visible fixtures' leaf certificate expired on +2026-10-01 at 17:43:59 UTC. The SDK checks expiry before permitted EKUs, so an +expired original cannot distinguish the negative profile mutation: it returns +`signingCredential.expired` for both profiles. Keep originals in attribution +checks and use the generated certificate for that mutation. Expiry does not +invalidate a signature made inside validity or prove that its asset changed. Microsoft's same fixtures also report `claim.malformed` under the newer SDK. Their soft-binding block stores a GUID as a text string, whereas @@ -586,6 +621,12 @@ 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. +The fallback soft-binding scan checks literal identifier presence before its +ASCII token-boundary regex. This avoids running every registry regex on files +without algorithm names and preserves exact matching, including rejection of +prefixes and version suffixes. Tests pin boundaries for every generated registry +entry and assert that an identifier-free buffer runs no regex searches. + 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 @@ -704,10 +745,15 @@ Key contracts: the guide's `Md`. Only the label signature is checked: MiniMax's binding signature also verifies, but no verdict reads it, and its content hash is not recomputed because MiniMax names no `CntSel`. The key travels in the file, so - only a key pinned in `TC260_SIGNING_KEYS` names a signer, and a pinned signer + only a key pinned in `TC260_PRODUCERS` (the source of `TC260_SIGNING_KEYS`) + names a signer, and a pinned signer is checked with the pinned point whatever parity the file claims; an unpinned - key that verifies proves integrity only and adds no signal. `ReservedCode2`, - the propagator's slot, is not read because no sample fills it. Verifications + key that verifies proves integrity only and adds no signal. Both reserved + fields preserve nested objects as JSON. Honor YOYO uses `PubSd`, RSA/SHA-256 + and `TBSData.Type=Content` with a null binding hash and no content selection; + this form is recognized and reported as unsupported, not absent or verified. + Only the producer slot is checked; Honor's repeated propagator slot adds no + independent verification. Verifications are memoized, since `get_ai_metadata` and the verdict check the same label. `tests/test_sm2.py` pins the GB/T 32905 SM3 vectors, OpenSSL's SM2 verify vectors, and the real MiniMax signatures. Over 411 local images and videos the @@ -799,6 +845,12 @@ metadata extraction from verdict logic: reported no SynthID for images the file path flagged. - `identify` preserves the path-based API and adds the optional registered visible-mark and open invisible-watermark detectors after extraction. +- The Google Photos SynthID caveat distinguishes the measured cohort from a + pixel verdict on the current file. Four earlier edits were oracle-positive + (2026-09-25); a large eraser and a restyle were positive while two small edits + were indeterminate (2026-10-01 UTC). Their C2PA shape does not distinguish + these outcomes. See [the Photos measurements](synthid.md) for the inference's + limits; the CLI does not call the provider verifier. - When the pixel stack is absent, the visible arm still no-ops (that is the historical `get_or_none` contract), but the report now carries a caveat saying the detectors did NOT run. Without it, an install lacking the `visible` extra @@ -1535,7 +1587,8 @@ regime: Alibaba, ByteDance, Kuaishou, Tencent, and other companies all use TC260 but their marks do not belong to one manufacturer family. `identify._VISIBLE_MARK_PLATFORM` and the signal mapping in `api.visible_provenance` are derived from those rows rather than hand-maintained -beside them, so registering a mark is one edit. Two marks carry no platform of +beside them. A TC260 mark also needs its producer row in `TC260_PRODUCERS`; +registry-coverage tests reject missing mappings. Two marks carry no platform of their own: the Gemini sparkle has its own higher-confidence path, and the pill alone is too weak to attribute. @@ -1547,7 +1600,8 @@ standard. Marks from other manufacturers cannot enable or veto the ByteDance-specific arm. A TC260 label relaxes the vendor its `ContentProducer` names, resolved through -`KnownMark.tc260_producer_codes`. The label itself is vendor-agnostic, so this used to +the shared producer table, which also supplies `KnownMark.tc260_producer_codes`. +The label itself is vendor-agnostic, so this used to relax ByteDance's two products on every China-AIGC image -- which both risked a false fill on an image carrying some other vendor's mark and denied that vendor's own mark the relaxed gate its `provenance_ncc_factor` was calibrated for. An diff --git a/docs/research-sweep-2026-09.md b/docs/research-sweep-2026-09.md index cbc3871..71c017a 100644 --- a/docs/research-sweep-2026-09.md +++ b/docs/research-sweep-2026-09.md @@ -25,7 +25,7 @@ This page is an index. Each finding lives in its canonical page. | Video labels | Sora discontinued; opening-only labels no longer filled over the whole clip; any video mark vetoed by another vendor's C2PA AI claim, Kling also by a non-Kling TC260 producer; TC260 video producers named | [supported-signals.md](supported-signals.md), [known-limitations.md](known-limitations.md) | | Apple | Image Playground and Photos Clean Up credits attributed | [supported-signals.md](supported-signals.md) | | New visible marks | `vidu` video mark and `wan` image mark, each calibrated on one real export | [supported-signals.md](supported-signals.md), [known-limitations.md](known-limitations.md) | -| Google Photos | AI edits reported as "Google Photos (AI edit)" rather than Gemini, with SynthID present: Google's checker found it in 4 of 4 Photos AI edits (Ask, eraser and two others) | [synthid.md](synthid.md), [supported-signals.md](supported-signals.md) | +| Google Photos | AI edits attributed to "Google Photos (AI edit)". Four earlier edits and two later large edits were SynthID-positive; two later small edits were indeterminate. Presence inferred from provenance needs file-specific confirmation | [synthid.md](synthid.md), [supported-signals.md](supported-signals.md) | | YouTube | Upload test: C2PA-driven "Made with AI" label, public streams without C2PA, Studio downloads re-signed by YouTube; re-encoded non-Google video no longer read as Google AI with SynthID | [supported-signals.md](supported-signals.md), [legal-and-safety.md](legal-and-safety.md), [module-internals.md](module-internals.md) | | Audio | Generated audio from OpenAI, ElevenLabs and Microsoft passes through the video path unchanged | [known-limitations.md](known-limitations.md) | | Attack literature | Fidelity band on open encoders now 30-45 dB; WmForger and WMCopier status | [synthid-removal-research.md](synthid-removal-research.md), [watermark-forgery-study.md](watermark-forgery-study.md) | @@ -53,9 +53,10 @@ This page is an index. Each finding lives in its canonical page. guide (TC260-PG-20258A) covers documents this image and video tool does not process. The security-protection guide (TC260-PG-202511A) records a `SecurityData` JSON object, a digital signature over the label and optionally - the content, in `ReservedCode1` and `ReservedCode2`. MiniMax is the one - producer in the local corpus that writes a guide-shaped object (checked - 2026-09-29): four MiniMax videos served by Higgsfield and Runway, two of them + the content, in `ReservedCode1` and `ReservedCode2`. MiniMax was the one + producer in the local corpus writing a guide-shaped object when checked + on 2026-09-29 (Honor RSA/content samples were added in the public-web + follow-up below): four MiniMax videos served by Higgsfield and Runway, two of them tracked (`higgsfield-hailuo-2-3.mp4`, `higgsfield-minimax-h3.mp4`), carry `SecurityData` with two SM3withSM2 (`1.2.156.10197.1.501`) signatures and one public key, `KeyValue` `00a0b3b0...47e4fd` in all four. The label signature @@ -148,22 +149,130 @@ This page is an index. Each finding lives in its canonical page. X300 camera capture carries a vivo device manifest (`digitalCapture`) that the bundled reader cannot open (`unknown algorithm`). - **Chinese phone galleries.** Huawei's help pages say the Xiaoyi photo-edit AI - watermark can be switched off; Honor, OPPO and Xiaomi label behavior is not - documented in any primary source found. vivo's Album setting is already - covered. + watermark can be switched off; vivo's Album setting is already covered. + The public-web follow-up below found Honor YOYO metadata and additional + primary-source labeling evidence, but not a controlled gallery export set. A live + conformance-list recheck on 2026-09-30 also found `Xiaomi MediaEditor` as a + generator product, `Xiaomi Gallery` as a validator, and both generator and + validator entries for `vivo Albums` / `JOVI Albums`. These roles describe C2PA + production and validation, not whether an edit is generative. Original exports + of an unedited control, an ordinary crop, and a generative edit are still needed + per app before selecting an AI attribution rule. Record the device, OS/app + version, region, operation, and original download path with each sample. - **Claims not supported by a primary source.** Kling embedding SynthID (repeated by remover blogs; Kuaishou is not in Google's adopter list), Hailuo pixel watermarks, and platforms stripping C2PA on upload. None is used by the library. -- **Google Photos small edits (deferred).** The "SynthID present" rule for - Photos AI edits rests on four checked edits, none of them tiny. Still to - measure on the phone app: a tiny eraser edit, a large one, a small "Help me - edit" change, a restyle, and a crop-only control, each checked in Gemini. iPhone - Mirroring does not show the Photos editing toolbar, and the web editor has no - generative tool, so this needs edits made on the device. +- **Google Photos small edits (six-file oracle check complete).** On + 2026-09-30, iPhone Mirroring successfully exposed the iOS + editing toolbar. One source and five separate copies were captured: a large + Magic eraser edit, a small removal through `Help me edit`, a small color + change through the same tool, a watercolor restyle, and a crop-only control. + The four AI exports carry Google Photos C2PA with valid signatures and asset + bindings; the signer remains untrusted without a configured trust-anchor list. + Neither the unchanged source nor the crop control has detected AI metadata. + Original-quality downloads, hashes, operation records, and metadata reports + are retained privately outside the repository. The restyle was exported at + 768 x 1365; the other AI copies retain the source's displayed dimensions. + The hash-bound Gemini batch passed complete verification on 2026-10-01 UTC: + large Magic Eraser and watercolor restyle were `detected`; small removal and + recoloring through `Help me edit` were `indeterminate`, with a possibly + too-small edit cited; source and crop were `not_detected`. AI metadata was + stripped without changing decoded pixels. All six settled replies were + recorded, one file per new chat. No quota refusal occurred and no request was + repeated. The Photos-specific report caveat now names the inconclusive small + edits rather than describing every tested AI edit as positive. A tiny Magic + eraser mask remains unmeasured: its automatic selection expanded to a + large region, so the small removal used `Help me edit` instead. One source and + one export per operation cannot establish representative or repeatable behavior. - **Captures blocked by region or device.** Doubao, Jimeng, Samsung. Higgsfield, Runway and Dreamina were captured on paid plans on 2026-09-24. +### Public-web sample search, 2026-10-01 UTC + +The follow-up searched official product/model pages, support articles, +provider communities, public share links, and public sample repositories. +Candidates were downloaded unchanged to a gitignored local evidence directory, +with source URLs, referring pages, SHA-256 hashes, metadata inspection, and +contact sheets. Website assets include icons, UI screenshots, source images, +outputs, and duplicate size variants; their count is not a count of genuine +provider exports. No third-party photograph was added to tracked fixtures. + +- **Honor YOYO: metadata sample found.** Two JPEGs in this + [author's edit thread](https://club.honor.com/cn/thread-29898091-1-1.html) + retain EXIF `UserComment` containing `AIGC`, producer + `001191440300MA5G49LC9K1YO01`, and nested `TC260PG` blocks with RSA/SHA-256 + OID `1.2.840.113549.1.1.11`. The author's post reports a Magic6 Pro; + OS/app versions are unknown. The downloaded files are 1312 x 736 and smaller + than the attachment sizes displayed by the forum, so original export fidelity + is unproven. Their visible overlay includes a community account attribution, + which is not evidence of an AI disclosure badge. The reader now preserves + nested reserved fields as JSON and attributes the measured code to Honor. + It reports the RSA/content signature as unsupported. + Public-key exponentiation on both signatures yields PKCS#1 type-1 padding + followed by a bare 32-byte value, without the SHA-256 DigestInfo prefix; + that value differs from the downloaded file hash. Together with null + `Bindings.Value`, `TBSData.Type=Content`, no content-selection method, and + forum resizing, this leaves asset integrity unverified. An embedded public + key alone would not establish manufacturer identity either. + Honor's [official privacy statement](https://agreement.itsec.honor.com/asm/agrFile/getHtmlFile?agrNo=1477&branchId=0&country=ru&langCode=en-US&version=20250517) + independently confirms the producer's USCC. Historical replay also found + that company code with a different product suffix; attribution names Honor, + because the USCC alone does not establish the application. A publication-safe + structural fixture and lossless metadata-removal test are documented in + [the fixture catalog](../data/fixtures/README.md#honor-yoyo-metadata-reconstruction). +- **vivo and Huawei: UI/source evidence, not original gallery exports.** + [vivo technical support](https://bbs.vivo.com.cn/newbbs/thread/39038829) + enumerates image/video AI operations that add the label, while a + [hands-on guide](https://bbs.vivo.com.cn/newbbs/thread/39038546?show_title=1) + locates `AI生成` at the upper right. Downloaded community illustrations show + settings and editing UI, not a verified signed Albums export. + [Huawei's Xiaoyi support article](https://consumer.huawei.com/cn/support/content/zh-cn16070847/) + supplies editing/save UI illustrations; those do not establish exported metadata. +- **OPPO: primary labeling statement found.** Its + [imaging product manager interview](https://www.oppo.com/en/newsroom/stories/empowering-creative-freedom-and-expression-in-mobile-photography-with-ai/) + states that AI-generated content is labeled. It does not specify the export + label's shape or metadata. Its downloadable press kit supplies before/after + JPEG pairs for Clarity Enhancer, Reflection Remover, and Unblur, with several + 3072 x 4096 images. These fetched pairs had no recognized C2PA/AIGC label; + they are useful local comparison material but not verified generative-edit + exports. Camera AI enhancement and ordinary camera-brand + watermarks remain separate from generative-edit provenance. +- **Jasper, Getty/iStock, RefaceAI: C2PA gaps remain.** Downloaded assets from + [Jasper's endpoint examples](https://images.jasper.ai/cleanup), + [Getty's generator page](https://www.gettyimages.com/ai/generation), + [iStock's generator page](https://www.istockphoto.com/ai/generation), and + [Reface's product site](https://reface.ai/) did not carry locally recognized + C2PA. Jasper explicitly distinguishes input/mask/output examples, but an + unsigned web derivative cannot establish its signed export's manifest shape. + Conformance certificates alone still do not justify fabricating that shape. +- **Visible-image/video gaps remain.** The downloaded RunningHub workflow + illustrations are explanatory/branding assets, not verified output examples; + Liblib API documentation's cat illustration has no visible historical wordmark. + A public [Inkpainting model page](https://www.liblib.art/modelinfo/2acd393c23924ff9b5363c9c1ec9129f) + explicitly attributes its example generation to Liblib; its read-only model + lookup supplied nine full PNG examples. These show ink-style illustrations + and a model cover, without the historical LiblibAI wordmark. + A public Vidu homepage MP4 was obtainable, but the inspected frame has no Vidu + mark. Seedance and Wan marketing assets, Dola extension screenshots, and + the Doubao share page did not initially yield marked consumer videos. A + subsequent public `samantha/media/get_play_info` lookup returned a 720 x 960, + 10.08-second H.264/AAC MP4 (SHA-256 + `0a000352e6f9b3e8417e57403572ef4a7a0e94fc8840217162e90c199a478def`). + Its decoded frame visibly carries `豆包AI生成` at bottom right; the file + remains local because publication has not been cleared. `identify_video` + attributes it to ByteDance Doubao and detects the mark on all 241 frames; + its MP4 TC260 producer is `001191110102MACQD9K64010000`. + Samsung's support illustrations are UI evidence, not a controlled original + export set. No new visible template or proprietary-watermark verdict follows + from these candidates. Xiaomi labeling tutorials surfaced, but their attribution + and original exports remain unverified. + +Next acquisition targets remain a signed export per missing C2PA signer, an +unedited/crop/generative-edit set per gallery, and original marked consumer +videos. Honor's nested RSA signature form is a separate verification gap; the +synthetic fixture deliberately does not pretend to close it. + ## Decisions - **Dreamina `AI` badge: not registered (2026-09-26).** Dreamina stamps a small diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 3fd261d..2f895f5 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -106,7 +106,10 @@ The inspection and stripping code handles signals in these groups: `ReservedCode1`: a signature that verifies against a pinned producer key is a high-confidence `aigc_signature` signal (MiniMax is the one pinned signer), a signature that does not verify is a caveat, and every result appears as the - `aigc_signature` metadata marker; + `aigc_signature` metadata marker. Nested JSON reserved fields are preserved; + Honor YOYO RSA/content signatures are reported as unsupported, with no + integrity or signer-trust claim. Its measured producer code attributes images + to Honor; - xAI and Grok signature-plus-UUID pairs in ordinary EXIF, ImageMagick PNG raw EXIF profiles, XMP description/creator fields, PNG Description/Author text, and IPTC Caption-Abstract/By-line fields; @@ -198,8 +201,11 @@ its chain, as a Gemini ingredient carries; with no AI claim in the chain, "Google Photos (AI edit)" with SynthID present, although the Photos manifest records no SynthID action and Google documents the mark only for Reimagine: Google's checker found SynthID in all four Photos AI edits tested on 2026-09-25 -(Ask, eraser and two other edits), and a caveat says the claim rests on that -measurement. Legacy OpenAI C2PA without that action +(Ask, eraser and two other edits). On 2026-10-01 UTC, a large Magic Eraser edit +and a watercolor restyle were positive, but two small `Help me edit` changes +were indeterminate; the source and crop control were negative. A caveat keeps +this provenance inference separate from a confirmed pixel watermark on the +particular file. Legacy OpenAI C2PA without that action does not assert SynthID. GPT Image 2.5 Flare and Sunburst were verified on 2026-09-15: both API outputs carried valid OpenAI C2PA with `c2pa.watermarked.unbound` and exercised the existing model-independent OpenAI @@ -301,8 +307,9 @@ not a universal clean verdict. | Baidu | Baidu mark | No registered pixel decoder | TC260 AIGC | | LiblibAI | LiblibAI wordmark and compact pill | No registered pixel decoder | TC260 AIGC | | RunningHub | RunningHub mark | No registered pixel decoder | TC260 AIGC | +| Honor YOYO AI editing | No registered AI mark; forum account overlay is separate | No registered pixel decoder | EXIF UserComment TC260 AIGC with nested RSA/content signature, reported unsupported | | Samsung Galaxy AI | One locale specific mark | No registered pixel decoder | C2PA and Samsung markers | -| Google Photos AI edits | None | SynthID, reported present: Google's checker found it in 4 of 4 Photos AI edits (2026-09-25), though the manifest records no SynthID action | C2PA signed "Google Photos" with a `compositeWithTrainedAlgorithmicMedia` action, plus XMP `photoshop:Credit` `Edited with Google AI` | +| Google Photos AI edits | None | SynthID provenance inference: 4/4 earlier edits positive (2026-09-25); large eraser and restyle positive, two small edits indeterminate (2026-10-01 UTC). No SynthID action in the manifest | C2PA signed "Google Photos" with a `compositeWithTrainedAlgorithmicMedia` action, plus XMP `photoshop:Credit` `Edited with Google AI` | | Apple Image Playground and Photos Clean Up | None | No registered pixel decoder; SynthID announced by Apple for a later update | XMP `photoshop:Credit` (`Apple Image Playground`, `Apple Photos Generative Edit: Clean Up`) plus IPTC digitalSourceType; no C2PA on measured 2026-09 output | For detector thresholds, measured limits, and incident history, see diff --git a/docs/synthid.md b/docs/synthid.md index 1bc056c..0b81db4 100644 --- a/docs/synthid.md +++ b/docs/synthid.md @@ -666,6 +666,17 @@ with their metadata stripped, found SynthID in all four on 2026-09-25: an Ask edit, an eraser edit and two other AI edits. The tool therefore reports SynthID as present for a Photos-signed AI edit and adds a caveat that the claim rests on that measurement, since Google notes a very small edit may not carry the mark. +An independent six-file check on 2026-10-01 UTC used one landscape source and +five iOS copies captured on 2026-09-30. The prepared uploads retained identical +decoded pixels and stripped AI provenance metadata. Large Magic Eraser and +watercolor restyle edits were positive; small object removal and recoloring +through `Help me edit` were indeterminate, with the checker noting a possibly +too-small edit. The unchanged source and square crop control were negative. +The four AI exports all carried Google Photos C2PA before stripping, so that +manifest shape does not distinguish a detectable watermark from an inconclusive +small edit. Indeterminate is not evidence of absence. Each operation has one +export, with no repetitions; a genuinely small Magic Eraser mask remains untested. +Private files and hash-bound oracle records remain outside the public repository. The Google Photos web editor is outside that rule: it has no generative tool and writes no Content Credentials, and Enhance, Sky and crop edits made there carried no SynthID in the same checker (2026-09-26, @@ -692,7 +703,8 @@ statements to "every Photos AI edit" is this project's inference, not Google's: "Edited with AI tools" appears on edits "with generative AI like Magic Eraser where new pixels are created", and an original without Content Credentials gets them "when an AI edit is saved". No Google page states whether Magic Eraser - applies SynthID; the eraser capture above is the only evidence here. + applies SynthID; the earlier eraser capture and the large eraser check above + are the measured evidence here. - The checker's own caveat ([Gemini Apps Help](https://support.google.com/gemini/answer/16722517)): "Likely too small an edit" (minor alterations might not carry a detectable watermark). diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 28ff5a9..409878b 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -18,6 +18,13 @@ Italian `✦ Contenuti generati dall'AI` bottom-left variant. Removal follows th same localize-then-fill path as other registered text marks. Other locales and icon-only variants need separate calibrated silhouettes. +**Honor YOYO AI editing** retains an EXIF `UserComment` TC260 label in two +public community JPEGs measured on 2026-10-01 UTC. The producer code now +attributes these images to Honor. Nested RSA/content signatures are recognized +but unsupported; forum resizing and unspecified content selection prevent an +asset-integrity verdict. Details and acquisition limits are in +[the public-web search](research-sweep-2026-09.md#public-web-sample-search-2026-10-01-utc). + **ASUS Gallery** also signs edited photos as C2PA (`com.asus.gallery`) but with no AI source type — a signer, not an AI marker. **Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)"). @@ -66,7 +73,7 @@ raw EXIF profile; Pillow exposes that form through `getexif()` even when `info["exif"]` is absent, so detection, removal, and portable records share that fallback. On the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding in-place scrub for supported EXIF values, TC260 AIGC blocks, and the xAI pair. -- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). The label says only "this is AI", but its `ContentProducer` names the signing entity — `001` + `1` + an 18-char Unified Social Credit Code + a 5-digit product suffix, normalized by `metadata.uscc_of`, or for a few generators a bare product name. `KnownMark.tc260_producer_codes` maps the codes settled per vendor by `scripts/vendor_cohort_harvest.py` to registry mark keys, so an AIGC image relaxes only the detector of the product it actually carries; an unmapped or absent producer leaves every product strict. The same mapping supplies the manufacturer for provenance-conflict checks. A code identifies a legal entity, not necessarily one brand, so a hosting or aggregating platform that signs for several apps is a recall bet rather than a proof. +- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). The label says only "this is AI", but its `ContentProducer` names the signing entity — `001` + `1` + an 18-char Unified Social Credit Code + a 5-digit product suffix, normalized by `metadata.uscc_of`, or for a few generators a bare product name. The shared `TC260_PRODUCERS` table maps the codes settled per vendor by `scripts/vendor_cohort_harvest.py` to registry mark keys and supplies `KnownMark.tc260_producer_codes`, so an AIGC image relaxes only the detector of the product it actually carries; an unmapped or absent producer leaves every product strict. The same mapping supplies the manufacturer for provenance-conflict checks, including the identity of a verified pinned TC260 signer; a signature under an unpinned embedded key cannot supply that identity. A code identifies a legal entity, not necessarily one brand, so a hosting or aggregating platform that signs for several apps is a recall bet rather than a proof. **Doubao** (ByteDance) uses it (verified on a public issue sample; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four image serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `{...}` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on compatible samples) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on compatible samples, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on compatible samples, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on compatible samples. The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). Native MP4/MOV is a fifth serialization: TC260-PG-20257A stores an `AIGC` key in `moov.udta.meta.keys` and the raw JSON in the matching `ilst` item; Doubao's iOS export additionally writes two QuickTime-form variants (a bare `meta` box as a direct `moov` child, and a keyless `hdlr=mdir` metadata list whose `ilst` data items carry the JSON with no key name) — both verified on retained carriers (2026-08-17). The seeking parser reaches a tail `moov` without reading `mdat`; removal replaces the key with `free` and blanks the validated value at the same length so every box size and stream offset stays fixed. All generic forms are gated on at least one TC260 field (`TC260_AIGC_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `TC260_AIGC_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, verified on compatible samples. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated). @@ -270,6 +277,7 @@ models used are paid-tier only. | YouTube, unlisted upload of the xAI Grok video | No AI label seen | Studio download re-signed the same way; the Grok manifest is self-signed and dropped by the reader | Was Google AI with SynthID; now unknown (committed fixture) | | Google Photos edit on iOS (object removal) | None | C2PA signed `Google Photos` (Google C2PA SDK for iOS), actions `c2pa.opened` and `c2pa.deleted` typed `compositeWithTrainedAlgorithmicMedia`, no SynthID action; XMP `photoshop:Credit` `Edited with Google AI` | Was reported as Gemini with SynthID present; now "Google Photos (AI edit)" with SynthID present. this file was not checked, but Google's checker found SynthID in four other Photos AI edits (Ask, eraser and two others, 2026-09-25) | | Google Photos on iOS: Ask (dog replaced by a cat), eraser and two other AI edits, 2026-09-24 and 2026-09-25 (`data/captures/2026-09/google-photos/`) | None | Same manifest shape, no action description and no SynthID action; Magic Editor, and so Reimagine, is not offered on iOS | "Google Photos (AI edit)" with SynthID present; Google's checker in Gemini found SynthID in all four with the metadata stripped | +| Google Photos on iOS: one source, large eraser, small Help me edit removal and recoloring, watercolor restyle, crop; captured 2026-09-30, checked 2026-10-01 UTC (private evidence) | None | All four AI exports carry Google Photos C2PA; source and crop have no AI metadata. Oracle uploads stripped AI metadata and preserved decoded pixels | Gemini: large eraser and restyle `detected`, both small edits `indeterminate` with a possibly too-small edit cited, source and crop `not_detected`. One export per operation; tiny Magic Eraser mask remains untested | | Apple Photos Clean Up | None | XMP `photoshop:Credit` `Apple Photos Generative Edit: Clean Up` plus `compositeWithTrainedAlgorithmicMedia`; no C2PA | Attributed to Apple Photos Clean Up | | Runway Gen-4 image and Gen-4.5 video (Standard plan, 2026-09-24) | None on either | C2PA signed `RUNWAY AI, INC.`, `c2pa.created` from `trainedAlgorithmicMedia` with software agent "Runway Image Generation" 4.0.0 or "Runway Video Generation" 4.5.0 | Was AI with an unknown signer and no platform; now Runway (committed fixtures) | | Nano Banana 2 run inside Runway | None | The original Google manifest with its SynthID action; Runway does not re-sign third-party models | Google with SynthID present, correct | diff --git a/src/remove_ai_watermarks/_internal/c2pa.py b/src/remove_ai_watermarks/_internal/c2pa.py index 9ea5256..3d6321f 100644 --- a/src/remove_ai_watermarks/_internal/c2pa.py +++ b/src/remove_ai_watermarks/_internal/c2pa.py @@ -754,8 +754,9 @@ def soft_binding_registry_entries_in(buffer: bytes) -> tuple[C2paSoftBindingAlgo return tuple( entry for entry in C2PA_SOFT_BINDING_REGISTRY - if re.search( - rb"(? str: + """Extract the USCC from ``001`` + type digit + USCC + product suffix.""" + return code[4:22] if len(code) >= 22 and code[:3] == "001" else code + + +_PRODUCERS_BY_CODE = {code.casefold(): row for row in TC260_PRODUCERS for code in row.codes} + + +def tc260_producer_in(producer: str, codes: Iterable[str]) -> bool: + """Match a whole producer identity, USCC-normalized and casefolded.""" + code = uscc_of(producer.strip()).casefold() + return bool(code) and any(code == candidate.casefold() for candidate in codes) + + +def producer_for_code(code: str) -> Tc260Producer | None: + """Resolve a producer field; partial names never establish an identity.""" + return _PRODUCERS_BY_CODE.get(uscc_of(code.strip()).casefold()) + + +def producer_for_signer(signer: str) -> Tc260Producer | None: + """Resolve the identity returned by successful pinned-key verification.""" + return next((row for row in TC260_PRODUCERS if row.signer == signer), None) + + +def producer_codes_for_mark(mark: str) -> tuple[str, ...]: + """Measured producer identities for an image or video mark, if registered.""" + row = next((row for row in TC260_PRODUCERS if mark in (row.image_mark, row.video_mark)), None) + return row.codes if row else () diff --git a/src/remove_ai_watermarks/_internal/tc260_signature.py b/src/remove_ai_watermarks/_internal/tc260_signature.py index 0569269..4969028 100644 --- a/src/remove_ai_watermarks/_internal/tc260_signature.py +++ b/src/remove_ai_watermarks/_internal/tc260_signature.py @@ -3,10 +3,10 @@ The guide lets a label producer record a ``SecurityData`` JSON object in the label's ``ReservedCode1``: SM3withSM2 signatures (``PubSD`` entries of type ``DS``), the public key (``PubKey``), and content bindings (``Bindings``). Only -the producer side is read here, because that is the only shape measured: MiniMax -writes it on every video in the local corpus, and ``ReservedCode2`` (the -propagator's slot) was empty on all of them. Other producers fill the reserved -fields with opaque vendor strings that are not ``SecurityData``. +the producer side is read here. MiniMax writes it on every video in the local +corpus. Honor YOYO JPEGs carry nested RSA/content signatures with ``PubSd`` +spelling in both reserved slots; those signatures are reported as unsupported. +Other measured producers use opaque vendor strings rather than ``SecurityData``. Only the label signature is checked. MiniMax spells its type ``LabelMataData`` where the guide says ``Md``; both are accepted. MiniMax's second signature, over @@ -25,11 +25,12 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, cast from remove_ai_watermarks._internal import sm2 +from remove_ai_watermarks._internal.tc260_producers import TC260_PRODUCERS if TYPE_CHECKING: from collections.abc import Mapping -Status = Literal["verified", "failed", "absent"] +Status = Literal["verified", "failed", "absent", "unsupported"] SM3_WITH_SM2 = "1.2.156.10197.1.501" @@ -41,12 +42,11 @@ def _pinned(x: int) -> sm2.Point: return point -# Label-signing keys pinned to the organization that holds them: the even-y point -# over the key's x coordinate. MiniMax's is the ``KeyValue`` carried by all four -# MiniMax videos in the local corpus (Hailuo 2.3, H3 and H3 Max, served by -# Higgsfield and Runway, 2026-09-29), with a leading ``00``. +# Derived from the same producer rows used by metadata and visible-mark routing. TC260_SIGNING_KEYS: dict[str, sm2.Point] = { - "MiniMax": _pinned(0xA0B3B0B6A0C9B0C89CAB328342AF4E8221EC5B40799CBE835AB4251F7B47E4FD), + row.signer: _pinned(row.signing_key_x) + for row in TC260_PRODUCERS + if row.signer is not None and row.signing_key_x is not None } _LABEL_TBS_TYPES = frozenset({"Md", "LabelMataData"}) @@ -68,6 +68,11 @@ class Tc260Signature: return "TC260 label signature verified with its own embedded key (no pinned signer)" if self.label == "failed": return "TC260 label signature does not verify" + if self.label == "unsupported": + return ( + "TC260 SecurityData signature algorithm or signed-content selection " + "is not supported; integrity unverified" + ) return "TC260 SecurityData without a label signature" @@ -130,7 +135,7 @@ def check_tc260_signature(label: Mapping[str, str]) -> Tc260Signature | None: security = _object(_object(parsed).get("SecurityData")) if security.get("Type") != "TC260PG": return None - public = _objects(security.get("PubSD")) + public = _objects(security.get("PubSD", security.get("PubSd"))) keys = { str(entry.get("KeyID", 0)): _candidate_keys(str(entry.get("KeyValue", ""))) for entry in reversed(public) # reversed: the first entry per KeyID wins @@ -138,11 +143,13 @@ def check_tc260_signature(label: Mapping[str, str]) -> Tc260Signature | None: } message = _label_message(label) results: list[bool] = [] + unsupported = False signer: str | None = None for entry in public: - if entry.get("Type") != "DS" or entry.get("AlgID") != SM3_WITH_SM2: + if entry.get("Type") != "DS": continue - if _object(entry.get("TBSData")).get("Type") not in _LABEL_TBS_TYPES: + if entry.get("AlgID") != SM3_WITH_SM2 or _object(entry.get("TBSData")).get("Type") not in _LABEL_TBS_TYPES: + unsupported = True continue points, key_signer = keys.get(str(entry.get("KeyID", 0)), ([], None)) try: @@ -153,5 +160,6 @@ def check_tc260_signature(label: Mapping[str, str]) -> Tc260Signature | None: results.append(verified) if verified: signer = signer or key_signer - status: Status = "absent" if not results else ("verified" if any(results) else "failed") - return Tc260Signature(label=status, signer=signer) + if results: + return Tc260Signature(label="verified" if any(results) else "failed", signer=signer) + return Tc260Signature(label="unsupported" if unsupported else "absent", signer=None) diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 63e735d..31fbda5 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -61,7 +61,7 @@ def visible_provenance(source: str | Path) -> frozenset[str]: Mapping: a Google/Gemini C2PA issuer -> ``"gemini"``; a ``samsung_genai`` marker -> ``"samsung"``; a China-AIGC (TC260) label -> the vendor its ``ContentProducer`` - names (``KnownMark.tc260_producer_codes``). An absent or unmapped producer confirms + names (the shared ``TC260_PRODUCERS`` registry). An absent or unmapped producer confirms no particular product: the TC260 standard identifies neither a manufacturer nor a product. @@ -96,11 +96,11 @@ def _tc260_vendors(path: Path) -> frozenset[str]: import contextlib with contextlib.suppress(Exception): - from remove_ai_watermarks.metadata import aigc_label, uscc_of + from remove_ai_watermarks.metadata import aigc_label from remove_ai_watermarks.watermark_registry import tc260_producer_mark producer = (aigc_label(path) or {}).get("ContentProducer", "") - if producer and (mark := tc260_producer_mark(uscc_of(producer))): + if producer and (mark := tc260_producer_mark(producer)): return frozenset({mark.key}) return frozenset() diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 0512b7f..8933c77 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -49,6 +49,7 @@ from remove_ai_watermarks._internal.constants import ( C2PA_SIGNER_PLATFORMS, ) from remove_ai_watermarks._internal.schema import require_schema_version +from remove_ai_watermarks._internal.tc260_producers import producer_for_code, producer_for_signer from remove_ai_watermarks._internal.tc260_signature import check_tc260_signature from remove_ai_watermarks.metadata import ( AI_METADATA_KEYS, @@ -129,8 +130,9 @@ _SYNTHID_CAVEAT = ( _GOOGLE_PHOTOS_EDIT = "Google Photos (AI edit)" _GOOGLE_PHOTOS_SYNTHID_CAVEAT = ( "SynthID on a Google Photos AI edit rests on measurement, not on the manifest: Google's checker " - "found it on every Photos AI edit tested (Ask, eraser and two other edits, 2026-09-25), but Google " - "notes a very small edit may not carry it. Confirm via the Gemini app." + "found it on four edits on 2026-09-25 and on large eraser and restyle edits on 2026-10-01 UTC, " + "but returned indeterminate results for two small edits. This provenance inference does not " + "confirm a detectable pixel watermark on this file. Confirm via the Gemini app." ) _C2PA_UNTRUSTED_CAVEAT = ( "The C2PA claim signature and asset binding validate, but no trust anchor list is configured here, " @@ -920,17 +922,6 @@ _C2PA_MANIFEST_SOURCE = "c2pa_manifest" _CLASH_SOURCE: dict[str, str] = {"c2pa": _C2PA_MANIFEST_SOURCE, "synthid": _C2PA_MANIFEST_SOURCE} -def _tc260_manufacturer_of(producer: str) -> str | None: - """Resolve a TC260 producer to a normalized manufacturer, if registered.""" - if not producer: - return None - from remove_ai_watermarks.metadata import uscc_of - from remove_ai_watermarks.watermark_registry import tc260_producer_mark - - mark = tc260_producer_mark(uscc_of(producer)) - return _vendor_of(mark.manufacturer if mark else None) - - def _integrity_clashes( ai_vendors: dict[str, str], camera_label: str | None, *, camera_has_ai_marker: bool ) -> list[str]: @@ -1489,13 +1480,19 @@ def _identify_from_evidence( watermarks.append("China AIGC label (TC260 standard)") if platform is None: platform = "China AIGC-labeled content (TC260 standard)" - if manufacturer := _tc260_manufacturer_of(producer): - ai_vendor_claims["aigc"] = manufacturer + if tc260_producer := producer_for_code(producer): + ai_vendor_claims["aigc"] = tc260_producer.vendor + if tc260_producer.image_platform and platform == "China AIGC-labeled content (TC260 standard)": + platform = tc260_producer.image_platform if aigc_data and (signature := check_tc260_signature(aigc_data)): if signature.label == "verified" and signature.signer: signals.append(Signal("aigc_signature", signature.describe(), "high")) + if signed_producer := producer_for_signer(signature.signer): + ai_vendor_claims["aigc"] = signed_producer.vendor elif signature.label == "failed": caveats.append(_TC260_SIGNATURE_FAILED_CAVEAT) + elif signature.label == "unsupported": + caveats.append(signature.describe()) # ── Local diffusion parameters (Stable Diffusion / ComfyUI) ────── local_keys = sorted(k for k in meta if k.lower() in _LOCAL_GEN_KEYS) diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 9451050..16aad90 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -14,6 +14,8 @@ import re import struct from typing import TYPE_CHECKING, Any, cast +from remove_ai_watermarks._internal.tc260_producers import uscc_of as uscc_of + if TYPE_CHECKING: from collections.abc import Callable, Iterable from pathlib import Path @@ -177,19 +179,14 @@ _TC260_CANONICAL_FIELDS: dict[str, str] = {field.casefold(): field for field in MAX_TC260_VALUE_BYTES = 1024 * 1024 -# A TC260 producer code is ``001`` + ``1`` + USCC(18) + a 5-digit app/product suffix, -# so two codes sharing the USCC are the same legal entity registering different -# products. Slicing is defensive: anything not matching the layout is returned as-is, -# which also passes through the bare-name forms some generators write ("doubao", -# "picwish"). -_USCC_START, _USCC_END = 4, 22 - - -def uscc_of(code: str) -> str: - """The 18-char Unified Social Credit Code embedded in a TC260 producer code.""" - if len(code) >= _USCC_END and code[:3] == "001": - return code[_USCC_START:_USCC_END] - return code +def _tc260_fields(parsed: dict[object, object]) -> dict[str, str]: + """Keep structured reserved fields as JSON rather than Python repr strings.""" + return { + _TC260_CANONICAL_FIELDS.get(str(key).casefold(), str(key)): ( + json.dumps(item, ensure_ascii=False, separators=(",", ":")) if isinstance(item, (dict, list)) else str(item) + ) + for key, item in parsed.items() + } def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None: @@ -202,10 +199,7 @@ def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None: return None if not isinstance(parsed, dict): return None - fields = { - _TC260_CANONICAL_FIELDS.get(str(key).casefold(), str(key)): str(item) - for key, item in cast("dict[object, object]", parsed).items() - } + fields = _tc260_fields(cast("dict[object, object]", parsed)) return fields if TC260_AIGC_FIELDS & fields.keys() else None @@ -553,7 +547,7 @@ def aigc_label_from_metadata(data: bytes, candidates: tuple[str, ...] = ()) -> d return None if not isinstance(parsed, dict): return None - return {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()} + return _tc260_fields(cast("dict[object, object]", parsed)) for candidate in candidates: if result := _parse(candidate, require_tc260_field=True): diff --git a/src/remove_ai_watermarks/video.py b/src/remove_ai_watermarks/video.py index 2d68a9d..d051a78 100644 --- a/src/remove_ai_watermarks/video.py +++ b/src/remove_ai_watermarks/video.py @@ -13,6 +13,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Literal +from remove_ai_watermarks._internal.tc260_producers import TC260_PRODUCERS from remove_ai_watermarks.video_synthid import ( DEFAULT_VIDEO_SYNTHID_FPS, DEFAULT_VIDEO_SYNTHID_LONG_SIDE, @@ -218,11 +219,12 @@ _VISIBLE_PLATFORM = { "sora": "OpenAI Sora", "veo": "Google Veo", "seedance": "ByteDance Seedance", - "doubao": "ByteDance Doubao", "dola": "ByteDance Dola", - "hailuo": "MiniMax Hailuo AI", - "vidu": "ShengShu Vidu", - "kling": "Kuaishou Kling AI", + **{ + row.video_mark: row.video_platform + for row in TC260_PRODUCERS + if row.video_mark is not None and row.video_platform is not None + }, } @@ -379,30 +381,11 @@ def _platform_from_video_metadata(markers: dict[str, str]) -> str | None: def _tc260_video_platform(producer: str) -> str | None: - """Name the organization a TC260 ``ContentProducer`` identifies, if registered. + """Name a registered TC260 producer, preserving multi-model organization labels.""" + from remove_ai_watermarks._internal.tc260_producers import producer_for_code - Codes come from the registry rows (plus the Vidu constant); the labels name - the producer, not one product, because one code can cover several models (the - Tongyi Yunqi code signs both Wan and HappyHorse video, measured 2026-09-24). - MiniMax writes its bare name instead of a USCC. - """ - from remove_ai_watermarks.video_visible import VIDU_TC260_PRODUCER_CODES, tc260_producer_in - from remove_ai_watermarks.watermark_registry import get_mark - - labels = { - "kling": _VISIBLE_PLATFORM["kling"], - "doubao": _VISIBLE_PLATFORM["doubao"], - "qwen": "Alibaba Cloud Qwen", - "wan": "Alibaba Tongyi (Wan, HappyHorse)", - } - for key, label in labels.items(): - if tc260_producer_in(producer, get_mark(key).tc260_producer_codes): - return label - if tc260_producer_in(producer, VIDU_TC260_PRODUCER_CODES): - return _VISIBLE_PLATFORM["vidu"] - if tc260_producer_in(producer, ("MiniMax",)): - return _VISIBLE_PLATFORM["hailuo"] - return None + row = producer_for_code(producer) + return row.video_platform if row else None def inspect_video_metadata(source: str | Path) -> VideoMetadataReport: diff --git a/src/remove_ai_watermarks/video_visible.py b/src/remove_ai_watermarks/video_visible.py index 7dd8a32..23a22ae 100644 --- a/src/remove_ai_watermarks/video_visible.py +++ b/src/remove_ai_watermarks/video_visible.py @@ -36,6 +36,8 @@ import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont +from remove_ai_watermarks._internal.tc260_producers import producer_codes_for_mark +from remove_ai_watermarks._internal.tc260_producers import tc260_producer_in as tc260_producer_in from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS from remove_ai_watermarks.video_encoding import ( abort_raw_video_encoder, @@ -50,7 +52,6 @@ from remove_ai_watermarks.video_encoding import ( from remove_ai_watermarks.video_temporal import stabilize_filled_frame if TYPE_CHECKING: - from collections.abc import Iterable from pathlib import Path from numpy.typing import NDArray @@ -110,9 +111,6 @@ _DOLA_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(22, 41)) _HAILUO_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(28, 56, 3)) _KLING_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(24, 49, 3)) _VIDU_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(34, 61, 3)) -# ShengShu (北京生数科技有限公司), Vidu's maker, as the USCC inside a TC260 -# ContentProducer. Verified against the Beijing municipal list of 2025-03-03. -VIDU_TC260_PRODUCER_CODES = ("91110108MACC4D63XF",) _HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"}) @@ -1641,19 +1639,9 @@ def has_bytedance_video_provenance(markers: dict[str, str]) -> bool: return ("bytedance" in identity or "byteplus" in identity) and "trainedalgorithmicmedia" in source_type -def tc260_producer_in(producer: str, codes: Iterable[str]) -> bool: - """Whether a TC260 ``ContentProducer`` names one of ``codes`` (USCC-normalized, casefolded).""" - from remove_ai_watermarks.metadata import uscc_of - - code = uscc_of(producer.strip()).casefold() - return bool(code) and code in {candidate.casefold() for candidate in codes} - - def has_doubao_video_provenance(markers: dict[str, str]) -> bool: """Whether the structural TC260 producer identifies registered Doubao provenance.""" - from remove_ai_watermarks.watermark_registry import get_mark - - return tc260_producer_in(markers.get("aigc_producer", ""), get_mark("doubao").tc260_producer_codes) + return tc260_producer_in(markers.get("aigc_producer", ""), producer_codes_for_mark("doubao")) # Tokens of the C2PA issuer each mark's own vendor signs with, compared casefolded. @@ -1708,9 +1696,7 @@ def contradicts_video_provenance(mark: str, markers: dict[str, str]) -> bool: return False producer = markers.get("aigc_producer", "").strip() if mark == "kling" and producer: - from remove_ai_watermarks.watermark_registry import get_mark - - return not tc260_producer_in(producer, get_mark("kling").tc260_producer_codes) + return not tc260_producer_in(producer, producer_codes_for_mark("kling")) from remove_ai_watermarks._internal.c2pa import c2pa_credential_level issuer = markers.get("issuer", "").casefold() @@ -1725,7 +1711,7 @@ def contradicts_video_provenance(mark: str, markers: dict[str, str]) -> bool: def has_vidu_video_provenance(markers: dict[str, str]) -> bool: """Whether a TC260 label names ShengShu, Vidu's maker, as the producer.""" - return tc260_producer_in(markers.get("aigc_producer", ""), VIDU_TC260_PRODUCER_CODES) + return tc260_producer_in(markers.get("aigc_producer", ""), producer_codes_for_mark("vidu")) def has_hailuo_video_provenance(markers: dict[str, str]) -> bool: @@ -1737,4 +1723,4 @@ def has_hailuo_video_provenance(markers: dict[str, str]) -> bool: ``metadata.get_ai_metadata``), so this matches the field exactly rather than parsing the human-readable ``aigc_label`` sentence. """ - return markers.get("aigc_producer", "").strip().lower() == "minimax" + return tc260_producer_in(markers.get("aigc_producer", ""), producer_codes_for_mark("hailuo")) diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index 4453044..4e15f85 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -49,6 +49,8 @@ if TYPE_CHECKING: from numpy.typing import NDArray +from remove_ai_watermarks._internal.tc260_producers import producer_codes_for_mark, producer_for_code + logger = logging.getLogger(__name__) Region = tuple[int, int, int, int] @@ -293,9 +295,8 @@ class KnownMark: provenance_platform_tokens: tuple[str, ...] = () # TC260 ``ContentProducer`` identities that name THIS mark's vendor -- Unified # Social Credit Codes as normalized by ``metadata.uscc_of``, plus the bare product - # names a few generators write instead. Here rather than in a separate table - # because a newly registered TC260 mark whose codes were forgotten fails SILENTLY: - # its own detector remains strict even on an image carrying that producer's label. + # names a few generators write instead. Derived from TC260_PRODUCERS; the + # registry-coverage test prevents a TC260 mark from silently omitting its codes. tc260_producer_codes: tuple[str, ...] = () # Optional single-pass dual verdict for the arbiter's perception stage (see # `detect_both`). None = fall back to two `_detect` calls. @@ -612,7 +613,6 @@ def _text_mark( product: str | None = None, label_regime: str | None = "tc260", provenance_signals: tuple[str, ...] = ("aigc",), - tc260_producer_codes: tuple[str, ...] = (), provenance_platform_tokens: tuple[str, ...] = (), ) -> KnownMark: """Build a text-mark registry row from its shared detector and mask adapters. @@ -635,7 +635,7 @@ def _text_mark( _engine_mark_detect(key, label, location), _engine_mark_mask(key), provenance_signals=provenance_signals, - tc260_producer_codes=tc260_producer_codes, + tc260_producer_codes=producer_codes_for_mark(key), provenance_platform_tokens=provenance_platform_tokens, _detect_both=_engine_mark_detect_both(key, label, location), ) @@ -696,7 +696,6 @@ _REGISTRY: tuple[KnownMark, ...] = ( "bottom-right", platform="ByteDance Doubao (visible 豆包AI生成 mark detected)", manufacturer="bytedance", - tc260_producer_codes=("91110102MACQD9K640", "doubao"), ), _text_mark( "jimeng", @@ -704,7 +703,6 @@ _REGISTRY: tuple[KnownMark, ...] = ( "bottom-right", platform="ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)", manufacturer="bytedance", - tc260_producer_codes=("9144030008867405X2",), ), _text_mark( "qwen", @@ -712,7 +710,6 @@ _REGISTRY: tuple[KnownMark, ...] = ( "bottom-right", platform="Alibaba Cloud Qwen (visible text or symbol mark detected)", manufacturer="alibaba", - tc260_producer_codes=("91440101MA9Y9T4H7A",), ), _text_mark( "wan", @@ -720,9 +717,6 @@ _REGISTRY: tuple[KnownMark, ...] = ( "bottom-right", platform="Alibaba Wan (visible Wan mark detected)", manufacturer="alibaba", - # Tongyi Yunqi (Hangzhou) Information Technology, owned by Alibaba Cloud and - # Tongyi Lab: the ContentProducer on the measured Wan export. - tc260_producer_codes=("91330106MA2CFLDG4R",), ), _text_mark( "kling", @@ -730,9 +724,6 @@ _REGISTRY: tuple[KnownMark, ...] = ( "bottom-right", platform="Kuaishou Kling AI (visible 可灵AI / KlingAI 3.0 mark detected)", manufacturer="kuaishou", - # Kling 3.0 Turbo video served by Higgsfield (2026-09-24) names the - # producer by the bare string "kling" instead of a USCC. - tc260_producer_codes=("91110108335469089C", "kling"), ), _text_mark( "yuanbao", @@ -740,7 +731,6 @@ _REGISTRY: tuple[KnownMark, ...] = ( "bottom-right", platform="Tencent Yuanbao (visible 元宝 / AI生成 mark detected)", manufacturer="tencent", - tc260_producer_codes=("91440300708461136T",), ), # Samsung Galaxy AI is a device editing marker (samsung_genai), not a TC260 label. _text_mark( @@ -758,7 +748,6 @@ _REGISTRY: tuple[KnownMark, ...] = ( "top-left", platform="RunningHub (visible RunningHub AI生成 mark detected)", manufacturer="runninghub", - tc260_producer_codes=("91340100MAEB4N8H76", "RunningHub"), ), _text_mark( "baidu", @@ -766,7 +755,6 @@ _REGISTRY: tuple[KnownMark, ...] = ( "bottom-right", platform="Baidu (visible 百度 AI生成 mark detected)", manufacturer="baidu", - tc260_producer_codes=("91110000802100433B",), ), _text_mark( "liblib", @@ -774,7 +762,6 @@ _REGISTRY: tuple[KnownMark, ...] = ( "bottom-center", platform="LiblibAI (visible LiblibAI mark detected)", manufacturer="liblib", - tc260_producer_codes=("91110105MACJ6K1C8A",), ), KnownMark( "liblib_pill", @@ -943,7 +930,8 @@ def resolve_trust( def tc260_producer_mark(code: str) -> KnownMark | None: """Return the registry row for one TC260 ``ContentProducer`` identity.""" - return next((mark for mark in _REGISTRY if code in mark.tc260_producer_codes), None) + producer = producer_for_code(code) + return get_mark(producer.image_mark) if producer and producer.image_mark else None def _pill_suppressors() -> set[str]: diff --git a/tests/test_c2pa_registry_sync.py b/tests/test_c2pa_registry_sync.py index 74635ac..977f9f4 100644 --- a/tests/test_c2pa_registry_sync.py +++ b/tests/test_c2pa_registry_sync.py @@ -146,3 +146,13 @@ class TestReadBytesAuth: monkeypatch.setenv("GITHUB_TOKEN", "test-token") headers = self._urlopen_headers("https://raw.githubusercontent.com/example/example/main/x.json") assert "Authorization" not in headers + + +@pytest.mark.parametrize("entry", C2PA_SOFT_BINDING_REGISTRY, ids=lambda entry: entry.algorithm) +def test_every_soft_binding_algorithm_keeps_exact_token_boundaries(entry): + from remove_ai_watermarks._internal.c2pa import soft_binding_registry_entries_in + + token = entry.algorithm.encode() + assert entry in soft_binding_registry_entries_in(b'"' + token + b'"') + for prefix, suffix in ((b"prefix", b""), (b"", b".suffix"), (b"-", b""), (b"", b"1")): + assert entry not in soft_binding_registry_entries_in(prefix + token + suffix) diff --git a/tests/test_identify.py b/tests/test_identify.py index 7c43e3f..181d6f6 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -30,7 +30,6 @@ from remove_ai_watermarks.identify import ( _attribute_platform, _integrity_clashes, _issuers_in, - _tc260_manufacturer_of, _vendor_of, evidence_from_metadata_record, extract_provenance_evidence, @@ -1870,9 +1869,13 @@ class TestVendorOf: class TestIntegrityClashesHelper: def test_tc260_manufacturer_comes_from_the_registered_producer(self): - assert _tc260_manufacturer_of("001191440101MA9Y9T4H7A00001") == "Alibaba" - assert _tc260_manufacturer_of("0011999999999999999999999") is None - assert _tc260_manufacturer_of("") is None + from remove_ai_watermarks._internal.tc260_producers import producer_for_code + + row = producer_for_code("001191440101MA9Y9T4H7A00001") + assert row is not None + assert row.vendor == "Alibaba" + assert producer_for_code("0011999999999999999999999") is None + assert producer_for_code("") is None def test_two_ai_vendors_clash(self): clashes = _integrity_clashes({"c2pa": "OpenAI", "exif_generator": "Ideogram"}, None, camera_has_ai_marker=True) @@ -2352,6 +2355,8 @@ class TestGooglePhotosSynthIdScope: Google documents SynthID only for Reimagine (blog.google, 2025-08-20), but its checker found the mark on all four Photos AI edits tested on 2026-09-25. + On 2026-10-01 UTC, large eraser and restyle edits were positive, while two + small edits were indeterminate. The report remains a provenance inference. The manifest shape mirrors a real Google Photos iOS object-removal edit captured 2026-09-23: signer common name "Google Photos", actions opened + deleted, the diff --git a/tests/test_metadata_audit_regressions.py b/tests/test_metadata_audit_regressions.py index 3bed0fc..8d8d40d 100644 --- a/tests/test_metadata_audit_regressions.py +++ b/tests/test_metadata_audit_regressions.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import shutil import subprocess +from datetime import UTC from pathlib import Path import piexif @@ -277,7 +278,7 @@ def test_exif_parser_failure_is_partial(tmp_path, monkeypatch): "visible/microsoft/provider-original.png", ], ) -def test_c2pa_signing_ekus_validate_without_trusting_the_signer(relative_path, monkeypatch): +def test_c2pa_signing_ekus_validate_without_trusting_the_signer(relative_path): from remove_ai_watermarks._internal import c2pa source = Path(__file__).resolve().parents[1] / "data" / "fixtures" / relative_path @@ -295,6 +296,79 @@ def test_c2pa_signing_ekus_validate_without_trusting_the_signer(relative_path, m assert "claim.malformed" in info["c2pa_validation_codes"] assert "com.microsoft.invismark.1" in info["soft_binding_algorithm"] + +def _signed_c2pa_profile_sample(tmp_path): + """Sign a neutral carrier with a local certificate whose dates we control.""" + from datetime import datetime + + from c2pa import Builder, C2paSignerInfo, Context, Signer + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ed25519 + + key = ed25519.Ed25519PrivateKey.generate() + name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "Synthetic C2PA profile test")]) + document_signing = "1.3.6.1.5.5.7.3.36" + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(1) + .not_valid_before(datetime(2020, 1, 1, tzinfo=UTC)) + .not_valid_after(datetime(2120, 1, 1, tzinfo=UTC)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.KeyUsage(True, False, False, False, False, False, False, None, None), critical=True) + .add_extension(x509.ExtendedKeyUsage([x509.ObjectIdentifier(document_signing)]), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False) + .sign(key, None) + ) + signer_info = C2paSignerInfo( + "ed25519", + certificate.public_bytes(serialization.Encoding.PEM), + key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()), + None, + ) + source = tmp_path / "neutral.jpg" + output = tmp_path / "signed.jpg" + Image.new("RGB", (32, 32), (50, 100, 150)).save(source) + manifest = { + "claim_generator": "Synthetic C2PA profile test", + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + } + ] + }, + } + ], + } + with ( + Context.from_dict({"trust": {"trust_config": document_signing}}) as context, + Signer.from_info(signer_info) as signer, + Builder(manifest, context=context) as builder, + source.open("rb") as input_stream, + output.open("wb") as output_stream, + ): + builder.sign(signer, "image/jpeg", input_stream, output_stream) + return output + + +def test_c2pa_wrong_eku_rejects_a_real_synthetic_signature(tmp_path, monkeypatch): + from remove_ai_watermarks._internal import c2pa + + source = _signed_c2pa_profile_sample(tmp_path) + accepted = c2pa.extract_c2pa_info(source) + assert accepted["c2pa_integrity"] == accepted["c2pa_signature"] == "valid" + assert accepted["c2pa_signer_validity"] == "valid" + assert accepted["c2pa_signer_trust"] == "untrusted" + assert identify(source, check_visible=False, check_invisible=False).is_ai_generated is True + # Exercise the real reader with an unrelated EKU, not a mocked validation result. monkeypatch.setattr(c2pa, "_C2PA_ALLOWED_EKUS", "1.2.3.4.5.99999") c2pa._manifest_json_cached.cache_clear() @@ -304,7 +378,7 @@ def test_c2pa_signing_ekus_validate_without_trusting_the_signer(relative_path, m assert rejected["c2pa_integrity"] == rejected["c2pa_signature"] == "valid" assert rejected["c2pa_signer_validity"] == "invalid" assert "signingCredential.invalid" in rejected["c2pa_validation_codes"] - assert identify(source, check_visible=False, check_invisible=False).platform is None + assert identify(source, check_visible=False, check_invisible=False).is_ai_generated is None finally: c2pa._manifest_json_cached.cache_clear() c2pa._extract_c2pa_info_cached.cache_clear() diff --git a/tests/test_metadata_internals.py b/tests/test_metadata_internals.py index 7a807a7..4ed9bc4 100644 --- a/tests/test_metadata_internals.py +++ b/tests/test_metadata_internals.py @@ -849,7 +849,7 @@ class TestC2paBufferScans: assert c2pa_info_from_manifest_store(store).get("synthid_vendors") == expected def test_google_photos_ai_edit_establishes_synthid_without_an_action(self): - """Byte-scan path: Google's checker found SynthID on every Photos AI edit tested.""" + """Byte-scan path keeps the measured Photos inference, not a pixel verdict.""" from remove_ai_watermarks._internal.c2pa import synthid_evidence_vendors_in edit = b"c2pa Google LLC Google Photos c2pa.deleted compositeWithTrainedAlgorithmicMedia" @@ -1265,3 +1265,21 @@ def test_byte_fallback_reads_ai_disclosure_label(): info: dict = {} assert _populate_registry_fields(b"jumb c2pa c2pa.ai-disclosure modelType human_validated", info) is True assert info["ai_source_kind"] == "enhanced" + + +def test_soft_binding_scan_skips_regex_when_no_identifier_is_present(monkeypatch): + from remove_ai_watermarks._internal import c2pa + + calls = [] + original_search = c2pa.re.search + + def recording_search(pattern, buffer, *args, **kwargs): + calls.append(pattern) + return original_search(pattern, buffer, *args, **kwargs) + + monkeypatch.setattr(c2pa.re, "search", recording_search) + assert c2pa.soft_binding_registry_entries_in(b"ordinary metadata without an algorithm identifier") == () + assert calls == [] + entries = c2pa.soft_binding_registry_entries_in(b'"com.microsoft.invismark.1"') + assert [entry.algorithm for entry in entries] == ["com.microsoft.invismark.1"] + assert len(calls) == 1 diff --git a/tests/test_platform.py b/tests/test_platform.py index f80e3dd..242604b 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -348,7 +348,7 @@ class TestVendorForStrength: @pytest.mark.parametrize(("integrity", "expected"), [("valid", "google"), ("invalid", None)]) def test_google_photos_ai_edit_keeps_the_google_cohort(self, integrity, expected): - """A Photos AI edit carries SynthID although its manifest records no SynthID action.""" + """A Photos AI edit keeps the inferred cohort without a recorded SynthID action.""" from remove_ai_watermarks._internal.c2pa import c2pa_info_from_manifest_store from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength diff --git a/tests/test_sm2.py b/tests/test_sm2.py index 0fa6a20..5b1ae70 100644 --- a/tests/test_sm2.py +++ b/tests/test_sm2.py @@ -177,12 +177,14 @@ class TestTc260Signature: assert check_tc260_signature({"Label": "1", "ReservedCode1": reserved}) is None -def _labeled_png(path: Path, label: dict[str, str]) -> Path: +def _labeled_png(path: Path, label: dict[str, str], text: dict[str, str] | None = None) -> Path: from PIL import Image from PIL.PngImagePlugin import PngInfo info = PngInfo() info.add_text("AIGC", json.dumps(label, separators=(",", ":"))) + for key, value in (text or {}).items(): + info.add_text(key, value) Image.new("RGB", (64, 64), (90, 120, 150)).save(path, pnginfo=info) return path @@ -200,6 +202,25 @@ class TestIdentifyIntegration: ] assert not any("does not verify" in caveat for caveat in report.caveats) + def test_pinned_signer_supplies_vendor_when_the_producer_lookup_is_unavailable(self, tmp_path, monkeypatch): + import remove_ai_watermarks.identify as identification + + path = _labeled_png( + tmp_path / "mixed.png", + _minimax_label(), + {"XML:com.adobe.xmp": "OpenAI"}, + ) + monkeypatch.setattr(identification, "producer_for_code", lambda code: None) + report = identification.identify(path, check_visible=False, check_invisible=False) + assert any(s.name == "aigc_signature" for s in report.signals) + assert any("MiniMax" in clash and "OpenAI" in clash for clash in report.integrity_clashes) + + # The same real signature under its embedded key cannot name a vendor. + monkeypatch.setattr(tc260_signature, "TC260_SIGNING_KEYS", {}) + unpinned = identification.identify(path, check_visible=False, check_invisible=False) + assert not any(s.name == "aigc_signature" for s in unpinned.signals) + assert not unpinned.integrity_clashes + def test_a_failed_signature_is_a_caveat_not_a_signal(self, tmp_path: Path) -> None: from remove_ai_watermarks.identify import identify diff --git a/tests/test_tc260_producers.py b/tests/test_tc260_producers.py new file mode 100644 index 0000000..8e79f50 --- /dev/null +++ b/tests/test_tc260_producers.py @@ -0,0 +1,51 @@ +"""Shared TC260 producer identity across signatures, metadata, and visible marks.""" + +from __future__ import annotations + +import pytest + +from remove_ai_watermarks._internal.tc260_producers import TC260_PRODUCERS, producer_for_code + + +@pytest.mark.parametrize("producer", TC260_PRODUCERS, ids=lambda row: row.key) +def test_every_identity_resolves_across_consumers(producer): + from remove_ai_watermarks.video import _tc260_video_platform + from remove_ai_watermarks.watermark_registry import tc260_producer_mark + + for code in producer.codes: + for spelling in (code, code.swapcase(), f" {code} "): + assert producer_for_code(spelling) is producer + assert _tc260_video_platform(spelling) == producer.video_platform + mark = tc260_producer_mark(spelling) + assert (mark.key if mark else None) == producer.image_mark + if len(code) == 18: + assert producer_for_code(f"0011{code}00001") is producer + assert producer_for_code(f"prefix-{producer.codes[0]}-suffix") is None + + +def test_producer_identities_and_image_marks_are_unambiguous(): + from remove_ai_watermarks.watermark_registry import get_mark, known_marks + + codes = [code.casefold() for row in TC260_PRODUCERS for code in row.codes] + assert len(codes) == len(set(codes)) + assert len({row.key for row in TC260_PRODUCERS}) == len(TC260_PRODUCERS) + assert all((row.signer is None) == (row.signing_key_x is None) for row in TC260_PRODUCERS) + image_marks = [row.image_mark for row in TC260_PRODUCERS if row.image_mark] + assert len(image_marks) == len(set(image_marks)) + for row in TC260_PRODUCERS: + if row.image_mark: + assert get_mark(row.image_mark).tc260_producer_codes == row.codes + assert all( + mark.key in image_marks for mark in known_marks() if mark.label_regime == "tc260" and mark.tc260_producer_codes + ) + + +@pytest.mark.parametrize("mark", ["doubao", "hailuo", "vidu"]) +def test_visible_video_confirmation_rejects_every_neighbor(mark): + from remove_ai_watermarks.video_visible import confirms_video_provenance + + for row in TC260_PRODUCERS: + for code in row.codes: + assert confirms_video_provenance(mark, {"aigc_producer": code}) == (row.video_mark == mark) + for code in ("", "unknown", f"prefix-{mark}-suffix"): + assert not confirms_video_provenance(mark, {"aigc_producer": code}) diff --git a/tests/test_web_sample_fixtures.py b/tests/test_web_sample_fixtures.py new file mode 100644 index 0000000..5e8dd1d --- /dev/null +++ b/tests/test_web_sample_fixtures.py @@ -0,0 +1,96 @@ +"""Publication-safe reconstructions of metadata observed in public web samples.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import piexif +from PIL import Image + +from remove_ai_watermarks._internal.tc260_signature import check_tc260_signature +from remove_ai_watermarks.identify import identify, identify_metadata_record +from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata +from remove_ai_watermarks.metadata_record import collect_metadata_record + + +def test_honor_yoyo_nested_security_data_round_trip(tmp_path: Path) -> None: + """Nested RSA-shaped fields remain readable and removable without a trust claim.""" + fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/honor-yoyo-tc260.json" + payload = json.loads(fixture.read_text()) + source = tmp_path / "synthetic-yoyo.jpg" + exif = piexif.dump({"Exif": {piexif.ExifIFD.UserComment: json.dumps(payload).encode()}}) + Image.new("RGB", (32, 32), (50, 100, 150)).save(source, exif=exif) + + label = aigc_label(source) + assert label is not None + assert label["ContentProducer"] == payload["AIGC"]["ContentProducer"] + assert "PubSd" in label["ReservedCode1"] + report = identify(source, check_visible=False, check_invisible=False) + assert report.is_ai_generated is True + signature = check_tc260_signature(label) + assert signature is not None + assert signature.label == "unsupported" + assert signature.signer is None # Synthetic RSA fields do not establish trust. + + with Image.open(source) as image: + before = image.tobytes() + cleaned = remove_ai_metadata(source, tmp_path / "cleaned.jpg") + assert aigc_label(cleaned) is None + assert identify(cleaned, check_visible=False, check_invisible=False).is_ai_generated is None + with Image.open(cleaned) as image: + assert image.tobytes() == before + + +def test_nested_security_data_is_preserved_as_json() -> None: + from remove_ai_watermarks.metadata import parse_tc260_aigc_json + + fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/honor-yoyo-tc260.json" + payload = json.loads(fixture.read_text()) + label = parse_tc260_aigc_json(json.dumps(payload["AIGC"]).encode()) + assert label is not None + assert json.loads(label["ReservedCode1"]) == payload["AIGC"]["ReservedCode1"] + + +def test_honor_attribution_and_unsupported_content_signature(tmp_path: Path) -> None: + fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/honor-yoyo-tc260.json" + payload = json.loads(fixture.read_text()) + source = tmp_path / "yoyo.jpg" + exif = piexif.dump({"Exif": {piexif.ExifIFD.UserComment: json.dumps(payload).encode()}}) + Image.new("RGB", (32, 32), (50, 100, 150)).save(source, exif=exif) + report = identify(source, check_visible=False, check_invisible=False) + assert report.platform == "Honor" + payload["AIGC"]["ContentProducer"] = "001191440300MA5G49LC9K1TK01" + exif = piexif.dump({"Exif": {piexif.ExifIFD.UserComment: json.dumps(payload).encode()}}) + other_product = tmp_path / "other-honor-product.jpg" + Image.new("RGB", (32, 32), (50, 100, 150)).save(other_product, exif=exif) + assert identify(other_product, check_visible=False, check_invisible=False).platform == "Honor" + assert any("not supported" in caveat for caveat in report.caveats) + record = json.loads(json.dumps(collect_metadata_record(source))) + assert identify_metadata_record(record, path=source) == report + label = aigc_label(source) + assert label is not None + signature = check_tc260_signature(label) + assert signature is not None + assert signature.label == "unsupported" + assert signature.signer is None + + +def test_namespaced_nested_security_data_is_preserved_as_json() -> None: + from remove_ai_watermarks.metadata import aigc_label_from_metadata + + fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/honor-yoyo-tc260.json" + payload = json.loads(fixture.read_text())["AIGC"] + packet = b"" + json.dumps(payload).encode() + b"" + label = aigc_label_from_metadata(packet) + assert label is not None + assert json.loads(label["ReservedCode1"]) == payload["ReservedCode1"] + + +def test_unsupported_signature_is_distinct_from_absent_signature() -> None: + fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/honor-yoyo-tc260.json" + payload = json.loads(fixture.read_text())["AIGC"] + payload["ReservedCode1"]["SecurityData"]["PubSd"] = [] + result = check_tc260_signature({"ReservedCode1": json.dumps(payload["ReservedCode1"])}) + assert result is not None + assert result.label == "absent"