mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-16 06:20:48 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3e0fa415 | ||
|
|
b3b868153f | ||
|
|
304bd071c0 | ||
|
|
2e26df1f21 | ||
|
|
d800ec9455 | ||
|
|
ef7b972ed1 | ||
|
|
b96ede6f44 |
No files matched your search
@@ -27,10 +27,12 @@ top-level Python API, and runtime detection/removal pipelines.
|
||||
|
||||
The unified maintainer entry point is `scripts/provider_oracles.py`; its workflow and
|
||||
slot schema live in `docs/provider-oracles.md`. Multi-account and multi-network work
|
||||
selects one named slot explicitly from the gitignored repository-local
|
||||
`.oracle-slots.json` by default and snapshots it into the immutable batch manifest.
|
||||
Never commit the local slot file.
|
||||
Do not add account or IP rotation, rate-limit failover, or cross-session result merging.
|
||||
selects each named slot explicitly from the gitignored repository-local
|
||||
`.oracle-slots.json` by default and snapshots it into a separate immutable batch
|
||||
manifest. A bounded provider check may continue through its finite set of authorized
|
||||
account slots after a refusal; do not add new logins, IP rotation, network-route
|
||||
failover, unbounded retries, or cross-session result merging. Never commit the local
|
||||
slot file.
|
||||
Every manual result stays bound to the exact sanitized upload hash and preserves the
|
||||
provider response verbatim.
|
||||
|
||||
|
||||
@@ -14,9 +14,24 @@ Named slots live in the gitignored repository-local
|
||||
pass `--slots PATH` only to override the local path.
|
||||
|
||||
Never substitute one provider's negative verdict for another provider's signal.
|
||||
Do not optimize candidates adaptively against an external oracle. A retry,
|
||||
different account, or different network is a new explicit operator decision,
|
||||
not automatic fallback.
|
||||
Do not optimize candidates adaptively against an external oracle.
|
||||
|
||||
## Account continuation
|
||||
|
||||
A request to verify a bounded set of files with a provider authorizes using that
|
||||
provider's configured account slots unless the user limits the account scope.
|
||||
Before starting, identify the finite set of available authenticated slots and
|
||||
prepare one immutable batch per slot. After a quota refusal or service failure,
|
||||
record the exact response, leave that slot's remaining rows unsubmitted, and
|
||||
continue automatically through the remaining authorized slots. Do not stop to
|
||||
request permission for each account or file already covered by the task.
|
||||
|
||||
Submit each frozen artifact at most once per slot in that pass. Preserve
|
||||
inconclusive results and disagreements between accounts. Stop when the bounded
|
||||
plan is complete or no authorized usable slot remains; report remaining rows.
|
||||
New logins, another provider, a new network route, or another pass after the plan
|
||||
is exhausted require authorization unless already requested. Account
|
||||
continuation does not authorize changing candidate pixels or erasing failures.
|
||||
|
||||
## Default surface priority
|
||||
|
||||
@@ -50,8 +65,10 @@ Playwright CLI runner, copied cookies, or a new login.
|
||||
AI metadata. Inspect the prepared upload again and do not submit it if a visible
|
||||
provider mark remains. A model answer based on a visible logo is not a SynthID
|
||||
result.
|
||||
4. Before each upload, request the action-time confirmation required for sending
|
||||
that exact prepared file to Gemini. Do not upload the source path directly.
|
||||
4. Confirm that the prepared files and provider are covered by the user's
|
||||
request or existing upload approval. Ask only if that scope is missing or
|
||||
changed; do not request confirmation again for each file or account in the
|
||||
authorized plan. Do not upload the source path directly.
|
||||
5. Attach one prepared upload and ask: `Does this image/video/audio contain a
|
||||
SynthID watermark? Use the SynthID verification tool only; do not infer from
|
||||
visible content, logos, text, or metadata.` Do not substitute a general
|
||||
@@ -62,9 +79,10 @@ Playwright CLI runner, copied cookies, or a new login.
|
||||
clean.
|
||||
7. Record the verbatim response and verify the completed batch.
|
||||
|
||||
If the selected Chrome profile or `/u/N/` account is not already authenticated,
|
||||
stop and ask the user to select the intended existing session. Do not sign in or
|
||||
switch to another account implicitly.
|
||||
If a planned Chrome profile or `/u/N/` account is not already authenticated,
|
||||
record that it is unavailable and continue with another authorized existing
|
||||
session. Ask the user only when no usable authorized session remains. Do not
|
||||
start a new login.
|
||||
|
||||
### OpenAI, Microsoft, and Meta Web
|
||||
|
||||
@@ -110,8 +128,9 @@ session continuity, not permission to retry a refused batch on another route.
|
||||
Use `check-openai` with one explicit upload acknowledgement. Keep named API keys
|
||||
as `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, and `OPENAI_API_KEY_3` in the local `.env`, and select
|
||||
only their variable names through an `openai-api` slot's `api_key_env`. Never
|
||||
print a key, copy one into a slot or manifest, or fall back automatically to a
|
||||
second key. API slots use a direct SDK transport that ignores environment proxy
|
||||
print a key or copy one into a slot or manifest. Continue through authorized
|
||||
named API slots according to the bounded account plan above; keep a separate
|
||||
batch for each. API slots use a direct SDK transport that ignores environment proxy
|
||||
settings. The current adapter is image-only.
|
||||
|
||||
### Microsoft API
|
||||
|
||||
@@ -18,11 +18,18 @@ updates:
|
||||
# extra) requires NumPy <2.4, mirrored by `constraint-dependencies` in
|
||||
# pyproject.toml, and this project keeps tokenizers below 0.23 for the stable
|
||||
# Transformers path. Keep Dependabot on the compatible lines until those
|
||||
# constraints move.
|
||||
# constraints move. Transformers 5.16+ requires tokenizers 0.23.1+,
|
||||
# and Diffusers requires huggingface-hub <2. Mirror the manifest bounds.
|
||||
ignore:
|
||||
- dependency-name: "numpy"
|
||||
versions:
|
||||
- ">=2.4"
|
||||
- dependency-name: "transformers"
|
||||
versions:
|
||||
- ">=5.16"
|
||||
- dependency-name: "huggingface-hub"
|
||||
versions:
|
||||
- ">=2"
|
||||
- dependency-name: "tokenizers"
|
||||
versions:
|
||||
- ">=0.23"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,34 @@ Python-dependent branches, so apply separate constraints on either side of
|
||||
Python 3.13. A blanket upgrade can both cross a NumPy major and downgrade a
|
||||
neighboring OCR package to satisfy the new graph.
|
||||
|
||||
### Dependency compatibility bounds
|
||||
|
||||
The published extras and Dependabot configuration carry matching bounds:
|
||||
|
||||
- Transformers stays below 5.16 while tokenizers stays on the verified 0.22
|
||||
line. [PyPI metadata for Transformers 5.16](https://pypi.org/pypi/transformers/5.16.0/json)
|
||||
and [5.17](https://pypi.org/pypi/transformers/5.17.0/json) requires tokenizers
|
||||
`>=0.23.1,<0.24`, so updating Transformers alone cannot resolve. Revalidate
|
||||
tokenizer construction and the model-loading paths before lifting both bounds.
|
||||
- Hugging Face Hub stays below 2 in every extra that names it. Diffusers 0.40
|
||||
[requires `huggingface-hub>=1.23,<2`](https://pypi.org/pypi/diffusers/0.40.0/json);
|
||||
a Hub-only extra must remain composable with the diffusion extra. Recheck
|
||||
Diffusers' declared requirements before lifting the Hub bound.
|
||||
- C2PA requires 0.38.0 or newer and uses an explicit per-reader certificate
|
||||
profile. Its Rust SDK 0.91.0 clears the bundled EKUs when loading empty trust
|
||||
settings, which otherwise rejects the OpenAI and Microsoft signing
|
||||
certificates in five committed fixtures. The profile restores those EKUs
|
||||
without installing trust anchors or bypassing certificate validation.
|
||||
Cause, upstream sources, and regression guards are in
|
||||
[Module internals, C2PA](module-internals.md#c2pa). Recheck both positive
|
||||
certificate acceptance and negative credential, signature, and asset-binding
|
||||
validation when upgrading the reader; retain every SDK validation status.
|
||||
|
||||
Dependabot ignore entries mirror these compatibility bounds. Its failed
|
||||
2026-09-30 run named the active Python 3.11 interpreter in a generic resolver
|
||||
hint, but the actual conflicts were the tokenizer and Hub requirements above;
|
||||
reducing the supported Python range does not fix them.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/test.yml` runs Ruff, a test matrix over every supported Python
|
||||
|
||||
+124
-8
@@ -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
|
||||
@@ -507,6 +533,59 @@ Regression coverage:
|
||||
official `c2pa-python` reader first. Its byte-level PNG parser remains a fallback
|
||||
for partial and synthetic fixtures that the official reader rejects.
|
||||
|
||||
Each read owns a `Context` with the six EKUs from the SDK's
|
||||
[bundled certificate profile](https://github.com/contentauth/c2pa-rs/blob/c2pa-v0.91.0/sdk/src/crypto/cose/valid_eku_oids.cfg).
|
||||
This sets permitted certificate purposes, not trusted signer identities. It
|
||||
adds neither trust anchors nor a certificate allowlist and leaves signature,
|
||||
asset-binding, and certificate verification enabled. The context outlives the
|
||||
reader and JSON serialization, then both native resources close.
|
||||
|
||||
The 2026-09-30 upgrade from c2pa-python 0.37.10 to 0.38.0 exposed a default-profile
|
||||
regression in Rust SDK 0.91.0:
|
||||
[`Store::from_context`](https://github.com/contentauth/c2pa-rs/blob/c2pa-v0.91.0/sdk/src/store.rs)
|
||||
now calls `ctp.clear()`, which clears EKUs as well as trust anchors, while
|
||||
production `Trust::default()` supplies no replacement `trust_config`.
|
||||
[SDK 0.90.19](https://github.com/contentauth/c2pa-rs/blob/c2pa-v0.90.19/sdk/src/store.rs)
|
||||
kept the bundled profile. This produced `signingCredential.invalid` despite
|
||||
valid signatures and asset hashes on `higgsfield-gpt-image.jpg`, the Bing,
|
||||
InvisMark, and Paint Microsoft provenance fixtures, and the Microsoft visible
|
||||
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. 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
|
||||
[C2PA 2.4, section 18.10](https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#soft_binding_assertion)
|
||||
requires a CBOR byte string. The newer
|
||||
[`SoftBindingBlock`](https://github.com/contentauth/c2pa-rs/blob/c2pa-v0.91.0/sdk/src/assertions/soft_binding.rs)
|
||||
uses bytes instead of the older SDK's `String`. Keep this failure in the
|
||||
validation codes and retain the raw signed assertion for marker inventory.
|
||||
A readable algorithm/GUID declaration does not establish schema validity or
|
||||
prove a pixel watermark. This does not bypass credential failures, and the
|
||||
asset-binding and signature dimensions remain independent of assertion schema
|
||||
validation.
|
||||
|
||||
The before/after metadata-only comparison for that upgrade preserved origin,
|
||||
platform, confidence, source kind, and all four validation dimensions across
|
||||
the retained corpus, with unchanged input hashes. SDK JSON and diagnostic codes
|
||||
changed: Microsoft gained the malformed-assertion status, other files gained
|
||||
OCSP-skipped or BMFF-exclusion notices, and some existing hash failures were
|
||||
reported twice. These diagnostics remain visible; report bytes are not identical.
|
||||
The comparison did not exercise pixel detection, removal, or GPU inference.
|
||||
|
||||
Structured extraction is limited to the active manifest and the ingredient
|
||||
manifests reachable from it. Validation is preserved as separate dimensions:
|
||||
asset binding integrity, claim signature, signer trust, and signer certificate
|
||||
@@ -542,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
|
||||
@@ -650,6 +735,29 @@ Key contracts:
|
||||
[`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py).
|
||||
- Native MP4/MOV TC260 `AIGC` entries are read from
|
||||
`moov.udta.meta.keys/ilst` and blanked without changing box sizes.
|
||||
- TC260 `SecurityData` signatures are checked by
|
||||
[`_internal/tc260_signature.py`](../src/remove_ai_watermarks/_internal/tc260_signature.py)
|
||||
on top of a verification-only SM3/SM2 in
|
||||
[`_internal/sm2.py`](../src/remove_ai_watermarks/_internal/sm2.py), because no
|
||||
dependency provides the SM2 curve. The label message is the guide's appendix
|
||||
A.1 string (`"Label":"1","ContentProducer":"...","ProduceID":"..."`) under the
|
||||
default SM2 user ID, and MiniMax's `LabelMataData` spelling is accepted beside
|
||||
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_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. 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
|
||||
only verdict-record change was the added marker on the nine MiniMax videos.
|
||||
- Native MP4/MOV `workflow` and `prompt` entries use that keyed metadata-list
|
||||
reader and the same offset-preserving removal path.
|
||||
- Native MKV/WebM TC260 `AIGC` entries are read from
|
||||
@@ -737,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
|
||||
@@ -1473,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.
|
||||
|
||||
@@ -1485,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
|
||||
|
||||
@@ -266,11 +266,22 @@ API slot must name its account or project and cannot name a browser profile. The
|
||||
tool records the network route that the operator has already established.
|
||||
Non-Google Web slots may name `proxy_url_env`; Playwright reads the secret proxy
|
||||
URL from that environment variable without writing or logging it. The tool does
|
||||
not rotate accounts or networks, retry through another IP after throttling, or
|
||||
merge sessions. Record a throttled response as `refused`; choosing another
|
||||
authorized slot is a separate operator decision and a separate batch. This
|
||||
preserves per-account and per-IP history without turning provider limits into an
|
||||
automatic evasion mechanism.
|
||||
not merge sessions or automatically change network routes. Account continuation
|
||||
is orchestrated by the skill, not by an individual adapter call. A request to
|
||||
verify a bounded set of files with a provider covers its configured account slots
|
||||
unless the operator restricts the account scope. Identify the finite set of
|
||||
available authenticated slots before starting and prepare one immutable batch
|
||||
per slot. Record quota refusals as `refused` and service failures according to
|
||||
their actual response, leave remaining rows in that slot unsubmitted, and
|
||||
continue automatically with the next authorized slot. Do not request permission
|
||||
again for files or accounts already covered by the task.
|
||||
|
||||
Submit each frozen artifact at most once per slot in that pass. Keep every
|
||||
verbatim refusal, inconclusive reply, and disagreement between accounts. Stop
|
||||
when the planned checks finish or no authorized usable slot remains, and report
|
||||
unsubmitted rows. A new login, another provider, a new network route, or a new
|
||||
pass after the bounded plan is exhausted needs authorization unless already
|
||||
requested. Candidate pixels remain frozen throughout account continuation.
|
||||
|
||||
Each `openai-api` slot must name exactly one `api_key_env`. Put the corresponding
|
||||
values in the gitignored local `.env`, never in the slot JSON:
|
||||
|
||||
+147
-12
@@ -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,8 +53,27 @@ 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`. The library accepts those
|
||||
fields as label evidence but does not verify the signature.
|
||||
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
|
||||
verifies on all four against the guide's appendix A.1 message, the ASCII string
|
||||
`"Label":"1","ContentProducer":"MiniMax","ProduceID":"<id>"`, with the default
|
||||
SM2 user ID `1234567812345678` and the key's x coordinate lifted to the even-y
|
||||
point; changing one `ProduceID` digit fails it. MiniMax's own spellings depart
|
||||
from the guide: `TBSData.Type` is `LabelMataData` (the guide's `Md`) and
|
||||
`Binding` with `BType` `0` (its `Bnd`); the second signature verifies over the
|
||||
compact JSON of the `Bindings` entry, also on all four. That entry's SM3
|
||||
content hash matched neither the whole file nor the file without its label,
|
||||
and it names no `CntSel`, so the content binding is unverified. Other producers fill the same
|
||||
fields with opaque vendor strings (Qwen a base64 digest, Vidu 32 hex digits,
|
||||
Wan and HappyHorse `L-`/`K-` prefixed values), not `SecurityData`. The key sits
|
||||
in the file it signs, so a check proves authenticity only against a pinned
|
||||
MiniMax key. `identify` now verifies it with a built-in SM3/SM2 and a pinned
|
||||
MiniMax key ([module internals](module-internals.md)).
|
||||
- **Experiments.** Chroma restoration after regeneration (arXiv:2508.21072) was
|
||||
measured on 2026-09-27 and is not shipped: transplanted source chroma drew one
|
||||
explicit "inconclusive" and two unclear Gemini answers where the untouched
|
||||
@@ -130,22 +149,138 @@ 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 (further work deferred, 2026-10-01).** The
|
||||
maintainer deferred further photographic tiny Magic Eraser captures and
|
||||
verification retries for the inconclusive small edits. Resume only on an
|
||||
explicit request; retain the existing evidence and uncertainty. 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 photographic
|
||||
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.
|
||||
Follow-up checks across two additional accounts left recoloring and the
|
||||
synthetic tiny Magic Eraser control inconclusive; small removal returned one
|
||||
negative and one inconclusive reply. The quota refusal, synthetic capture,
|
||||
pixel comparison, mode caveat, and hash-bound outcomes are recorded in
|
||||
[synthid.md](synthid.md).
|
||||
- **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
|
||||
|
||||
@@ -102,6 +102,14 @@ The inspection and stripping code handles signals in these groups:
|
||||
`script.onMetaData.AIGC` placements, plus the audio placements of
|
||||
TC260-PG-202510A: a RIFF `AIGC` chunk in WAV, an ID3v2 `TXXX` frame described
|
||||
`AIGC` in MP3, and an `AIGC=` Vorbis comment in OGG, Opus and FLAC;
|
||||
- TC260-PG-202511A `SecurityData` label signatures (SM3withSM2) in a label's
|
||||
`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. 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;
|
||||
@@ -193,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
|
||||
@@ -296,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
|
||||
|
||||
+44
-1
@@ -666,6 +666,48 @@ 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; a genuinely small photographic Magic Eraser mask remains untested.
|
||||
A later same-day repeat of the small-removal bytes was explicitly quota-refused before any new
|
||||
watermark verdict. The recoloring repeat was left unsubmitted after that refusal,
|
||||
so neither original indeterminate outcome changed. A follow-up iOS capture
|
||||
attempt again expanded the small-object selection to the foreground, including
|
||||
in `Refine selection`; it was cancelled without saving a new export.
|
||||
A separate synthetic control then isolated a small red disk on a 2048 x 2048
|
||||
gray image. The real iOS Magic Eraser selected the disk and saved a separate
|
||||
copy with valid C2PA signature and asset binding. Against the downloaded Photos
|
||||
source, 7,399 pixels (0.1764%) differed by more than three RGB channel levels,
|
||||
inside a 98 x 98 bounding box. Lower-level differences extended across the
|
||||
image, so this measures visible change, not the internal mask. The Photos source
|
||||
download also differed from the locally generated JPEG; the downloaded source
|
||||
is the comparison baseline. After the operator authorized multiple existing
|
||||
accounts, two additional authenticated sessions checked the same four frozen
|
||||
uploads: small photographic removal, recoloring, synthetic source, and tiny
|
||||
Magic Eraser export. Both four-row batches passed complete hash verification;
|
||||
neither session quota-refused. The first returned `not_detected` for small
|
||||
removal and the synthetic source, and `indeterminate` for recoloring and the
|
||||
tiny export. The second returned `indeterminate` for all four. Both tiny-export
|
||||
replies cited insufficient image detail; the second also gave that reason for
|
||||
the synthetic source. A flat control therefore cannot isolate the effect of
|
||||
edit size on verification. Small photographic removal now has one negative and
|
||||
two inconclusive observations, including the original check; recoloring has
|
||||
three inconclusive observations. Preserve the earlier quota refusal separately.
|
||||
The additional sessions used their existing Flash and Pro modes respectively,
|
||||
so the discrepancy is not attributable to account identity alone. These
|
||||
bounded checks establish observed outcomes, not repeatability or watermark
|
||||
absence. Further photographic tiny Magic Eraser captures and verification
|
||||
retries for these inconclusive small edits were deferred by the maintainer on
|
||||
2026-10-01. Resume only on an explicit request; the observations remain valid
|
||||
as recorded, with their uncertainty unresolved.
|
||||
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 +734,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).
|
||||
|
||||
@@ -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 `<TC260:AIGC>{"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 `<TC260:AIGC>{"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 `<TC260:AIGC>{...}</TC260:AIGC>` (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,8 @@ 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; photographic tiny Magic Eraser mask remains untested. A later retry was quota-refused. Two additional accounts then returned `not_detected`/`indeterminate` for small removal and `indeterminate`/`indeterminate` for recoloring on identical upload bytes; existing Flash/Pro modes differ |
|
||||
| Google Photos on iOS: synthetic 2048 x 2048 red disk on gray, small Magic Eraser, captured 2026-10-01 | None | Real edit export has valid C2PA signature and asset binding; signer trust unverified. Downloaded-source RGB comparison above three channel levels changes 0.1764% within 98 x 98; lower-level differences extend across the image | Two additional accounts: tiny export `indeterminate` in both, citing insufficient detail; source `not_detected`/`indeterminate`. Both immutable four-file batches complete and hash-verified. Source download differs from local JPEG; flat-image ambiguity prevents isolating edit size, and this is not a photographic replication |
|
||||
| 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 |
|
||||
|
||||
+15
-12
@@ -4,7 +4,7 @@
|
||||
# on it, including the ComfyUI node package. The console script below carries
|
||||
# the same weight, since users have it on PATH.
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.43.0"
|
||||
version = "0.44.0"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
@@ -59,7 +59,7 @@ dependencies = [
|
||||
# synthetic/partial blobs the validator rejects. Binary wheel (Rust), but the
|
||||
# import is light (no torch/numpy) so it fits the dependency-light identify
|
||||
# host. Prebuilt wheels cover the full CI matrix (linux/macos/windows).
|
||||
"c2pa-python>=0.35.0",
|
||||
"c2pa-python>=0.38.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -104,9 +104,11 @@ diffusion = [
|
||||
# 5.x but the tokenizers *release candidate* (0.23.0rc0) that the global
|
||||
# ``prerelease = "allow"`` drags in: its CLIP tokenizer raises
|
||||
# ``RobertaProcessing.__new__() got an unexpected keyword argument 'cls'``.
|
||||
# Cap tokenizers to the stable 0.22 line (transformers 5.x accepts
|
||||
# >=0.22,<=0.23.0) so the rc is excluded while SDXL still loads.
|
||||
"transformers>=5,<6",
|
||||
# Cap tokenizers to the verified stable 0.22 line so the rc is excluded
|
||||
# while SDXL still loads.
|
||||
# Transformers 5.16+ requires tokenizers 0.23.1+, outside the verified
|
||||
# stable tokenizer line below. Keep the published extras jointly resolvable.
|
||||
"transformers>=5,<5.16",
|
||||
"tokenizers>=0.22,<0.23",
|
||||
"accelerate>=0.25.0",
|
||||
"safetensors",
|
||||
@@ -131,10 +133,11 @@ text-restoration = [
|
||||
# Draft-only OCR proposals for verified-text manifests. CPU, no torch:
|
||||
# PaddleOCR detection plus three script-chosen recognition engines. The models
|
||||
# download on first use and are cached by huggingface_hub; never bundled.
|
||||
# Keep every Hub extra below 2 so it remains composable with Diffusers (<2).
|
||||
text-draft = [
|
||||
"paddleocr>=3.3.3",
|
||||
"paddlepaddle>=3.0.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
"huggingface-hub>=0.20.0,<2",
|
||||
]
|
||||
# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable
|
||||
# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional
|
||||
@@ -150,9 +153,9 @@ trustmark = [
|
||||
classify = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"torch>=2.0.0",
|
||||
"transformers>=5,<6",
|
||||
"transformers>=5,<5.16",
|
||||
"tokenizers>=0.22,<0.23",
|
||||
"huggingface-hub>=0.20.0",
|
||||
"huggingface-hub>=0.20.0,<2",
|
||||
]
|
||||
# Optional vision-only CPU runtime for the photo classifier. Keep it separate
|
||||
# because PyTorch remains the default and ONNX Runtime is needed only on opt-in.
|
||||
@@ -165,7 +168,7 @@ classify-onnx = [
|
||||
# SynthID detector. Weights download on first use.
|
||||
source-classify = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"huggingface-hub>=0.20.0",
|
||||
"huggingface-hub>=0.20.0,<2",
|
||||
]
|
||||
# Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX,
|
||||
# Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and
|
||||
@@ -174,7 +177,7 @@ source-classify = [
|
||||
lama = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
"onnxruntime>=1.24.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
"huggingface-hub>=0.20.0,<2",
|
||||
]
|
||||
# Lightweight inpaint backend -- MI-GAN via onnxruntime (andraniksargsyan/migan,
|
||||
# MIT). CPU, no torch. Model (~28 MB) downloaded on first use and cached by
|
||||
@@ -184,7 +187,7 @@ lama = [
|
||||
migan = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
"onnxruntime>=1.24.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
"huggingface-hub>=0.20.0,<2",
|
||||
]
|
||||
dev = [
|
||||
"remove-ai-watermarks[video]",
|
||||
@@ -196,7 +199,7 @@ dev = [
|
||||
# scripts/audioseal_experiment.py provenance study. Not part of the
|
||||
# installed CLI, the public Python API, or any runtime pipeline.
|
||||
"audioseal>=0.2.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
"huggingface-hub>=0.20.0,<2",
|
||||
# Direct Microsoft Content Provenance API transport. The adapter uses an
|
||||
# Azure CLI Entra token and ignores process proxy settings.
|
||||
"httpx>=0.28,<1",
|
||||
|
||||
@@ -35,7 +35,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.43.0"
|
||||
__version__ = "0.44.0"
|
||||
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Generated C2PA soft-binding registry; do not edit by hand.
|
||||
|
||||
Source: https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json
|
||||
Revision: 1b3ef637890ea8228bca13d8941cfd54da941867
|
||||
Revision: d36d1c1742b1753ca064bb3307abfd95db912a28
|
||||
Source license: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)
|
||||
Changes: validated, ordered, and reduced to fields used at runtime; descriptions
|
||||
are shortened to their first sentence for display.
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
C2PA_SOFT_BINDING_SOURCE_URL = (
|
||||
"https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json"
|
||||
)
|
||||
C2PA_SOFT_BINDING_SOURCE_REVISION = "1b3ef637890ea8228bca13d8941cfd54da941867"
|
||||
C2PA_SOFT_BINDING_SOURCE_REVISION = "d36d1c1742b1753ca064bb3307abfd95db912a28"
|
||||
C2PA_SOFT_BINDING_SOURCE_LICENSE = "CC BY 4.0"
|
||||
|
||||
C2PA_SOFT_BINDING_ROWS: tuple[
|
||||
@@ -575,7 +575,7 @@ C2PA_SOFT_BINDING_ROWS: tuple[
|
||||
49,
|
||||
"com.museblossom.contentsdefence.1",
|
||||
"watermark",
|
||||
("image",),
|
||||
("audio", "image", "video"),
|
||||
(),
|
||||
(
|
||||
"Contents Defence invisible image watermark, used to recover the associated C2PA "
|
||||
|
||||
@@ -35,13 +35,28 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from typing import BinaryIO
|
||||
|
||||
_C2paContext: Any = None
|
||||
_C2paReader: Any = None
|
||||
_C2paError: Any = None
|
||||
with contextlib.suppress(Exception):
|
||||
from c2pa import C2paError as _C2paError # pyright: ignore[reportMissingTypeStubs]
|
||||
from c2pa import Context as _C2paContext # pyright: ignore[reportMissingTypeStubs]
|
||||
from c2pa import Reader as _C2paReader # pyright: ignore[reportMissingTypeStubs]
|
||||
|
||||
_C2PA_READER_AVAILABLE = _C2paReader is not None
|
||||
_C2PA_READER_AVAILABLE = _C2paReader is not None and _C2paContext is not None
|
||||
# Preserve the SDK's bundled certificate profile explicitly: c2pa-rs 0.91.0
|
||||
# clears its default EKUs when applying empty trust settings. No trust anchors
|
||||
# or certificate allowlist are installed. See docs/module-internals.md, C2PA.
|
||||
_C2PA_ALLOWED_EKUS = "\n".join(
|
||||
(
|
||||
"1.3.6.1.5.5.7.3.4", # email protection
|
||||
"1.3.6.1.5.5.7.3.36", # document signing
|
||||
"1.3.6.1.5.5.7.3.8", # timestamping
|
||||
"1.3.6.1.5.5.7.3.9", # OCSP signing
|
||||
"1.3.6.1.4.1.311.76.59.1.9", # Microsoft C2PA signing
|
||||
"1.3.6.1.4.1.62558.2.1", # C2PA signing
|
||||
)
|
||||
)
|
||||
_PNG_HEADER = struct.Struct(">I4s")
|
||||
|
||||
_CONTENT_BINDING_MATCHES = (
|
||||
@@ -101,28 +116,30 @@ def _manifest_json_uncached(path: str, *, strict: bool = False) -> str | None:
|
||||
byte scan and can lose a high-confidence signal. The log line preserves the
|
||||
diagnostic context needed to investigate an intermittent reader failure.
|
||||
"""
|
||||
try:
|
||||
reader = _C2paReader.try_create(path)
|
||||
except _C2paError.NotSupported as error:
|
||||
logger.debug("C2PA reader does not support %s: %s", path, error)
|
||||
return None
|
||||
except Exception as error:
|
||||
logger.warning("C2PA reader failed to open %s: %s: %s", path, type(error).__name__, error)
|
||||
if strict:
|
||||
raise
|
||||
return None
|
||||
if reader is None:
|
||||
return None
|
||||
try:
|
||||
with reader:
|
||||
return cast("str", reader.json())
|
||||
except Exception as error:
|
||||
# The reader opened the file, so a manifest is there; failing to serialize it
|
||||
# is never routine.
|
||||
logger.warning("C2PA reader could not serialize %s: %s: %s", path, type(error).__name__, error)
|
||||
if strict:
|
||||
raise
|
||||
return None
|
||||
with contextlib.ExitStack() as resources:
|
||||
try:
|
||||
context = resources.enter_context(_C2paContext.from_dict({"trust": {"trust_config": _C2PA_ALLOWED_EKUS}}))
|
||||
reader = _C2paReader.try_create(path, context=context)
|
||||
except _C2paError.NotSupported as error:
|
||||
logger.debug("C2PA reader does not support %s: %s", path, error)
|
||||
return None
|
||||
except Exception as error:
|
||||
logger.warning("C2PA reader failed to open %s: %s: %s", path, type(error).__name__, error)
|
||||
if strict:
|
||||
raise
|
||||
return None
|
||||
if reader is None:
|
||||
return None
|
||||
try:
|
||||
with reader:
|
||||
return cast("str", reader.json())
|
||||
except Exception as error:
|
||||
# The reader opened the file, so a manifest is there; failing to serialize it
|
||||
# is never routine.
|
||||
logger.warning("C2PA reader could not serialize %s: %s: %s", path, type(error).__name__, error)
|
||||
if strict:
|
||||
raise
|
||||
return None
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8)
|
||||
@@ -737,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"(?<![A-Za-z0-9.-])" + re.escape(entry.algorithm.encode()) + rb"(?![A-Za-z0-9.-])",
|
||||
if (algorithm := entry.algorithm.encode()) in buffer
|
||||
and re.search(
|
||||
rb"(?<![A-Za-z0-9.-])" + re.escape(algorithm) + rb"(?![A-Za-z0-9.-])",
|
||||
buffer,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -197,6 +197,8 @@ C2PA_ISSUERS = {vendor.issuer: vendor.org for vendor in C2PA_AI_VENDORS}
|
||||
# absent: its manifests record no SynthID action, yet Google's checker found
|
||||
# SynthID on all four Photos AI edits tested (Ask, eraser and two other edits,
|
||||
# 2026-09-25), although Google documents the mark only for Reimagine.
|
||||
# Two small edits were oracle-indeterminate on 2026-10-01 UTC; Photos inference
|
||||
# therefore carries a file-specific confirmation caveat in the report.
|
||||
SYNTHID_EDIT_SIGNERS: tuple[bytes, ...] = (b"YouTube",)
|
||||
C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if vendor.asserts_ai)
|
||||
C2PA_SIGNER_PLATFORM_BY_ORG = {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""SM3 hashing and SM2 signature verification (GB/T 32905, GB/T 32918).
|
||||
|
||||
Verification only: no key generation, signing, or secret material, so none of the
|
||||
constant-time concerns of a signing implementation apply. The TC260 security
|
||||
guide (TC260-PG-202511A) names SM3withSM2 for AI-label signatures, and no
|
||||
dependency of this package provides SM2 (``cryptography`` has SM3 but not the
|
||||
curve). Correctness is pinned by the standard's SM3 vectors, OpenSSL's SM2 verify
|
||||
vectors, and real MiniMax label signatures in ``tests/test_sm2.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
Point = tuple[int, int]
|
||||
|
||||
# The SM2 recommended 256-bit curve (GB/T 32918.5).
|
||||
P = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF
|
||||
A = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC
|
||||
B = 0x28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93
|
||||
N = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123
|
||||
G: Point = (
|
||||
0x32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7,
|
||||
0xBC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0,
|
||||
)
|
||||
|
||||
# GB/T 32918.2's default signer identity, used when a signature names none.
|
||||
DEFAULT_USER_ID = b"1234567812345678"
|
||||
|
||||
_SM3_IV = (0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600, 0xA96F30BC, 0x163138AA, 0xE38DEE4D, 0xB0FB0E4E)
|
||||
_MASK = 0xFFFFFFFF
|
||||
|
||||
|
||||
def _rotl(x: int, n: int) -> int:
|
||||
n %= 32
|
||||
return ((x << n) | (x >> (32 - n))) & _MASK
|
||||
|
||||
|
||||
def _p0(x: int) -> int:
|
||||
return x ^ _rotl(x, 9) ^ _rotl(x, 17)
|
||||
|
||||
|
||||
def _p1(x: int) -> int:
|
||||
return x ^ _rotl(x, 15) ^ _rotl(x, 23)
|
||||
|
||||
|
||||
def sm3(data: bytes) -> bytes:
|
||||
"""SM3 digest of ``data`` (32 bytes)."""
|
||||
bit_length = len(data) * 8
|
||||
padded = data + b"\x80" + b"\x00" * ((55 - len(data)) % 64) + bit_length.to_bytes(8, "big")
|
||||
v = list(_SM3_IV)
|
||||
for offset in range(0, len(padded), 64):
|
||||
w = list(struct.unpack(">16I", padded[offset : offset + 64]))
|
||||
for j in range(16, 68):
|
||||
w.append(_p1(w[j - 16] ^ w[j - 9] ^ _rotl(w[j - 3], 15)) ^ _rotl(w[j - 13], 7) ^ w[j - 6])
|
||||
w1 = [w[j] ^ w[j + 4] for j in range(64)]
|
||||
a, b, c, d, e, f, g, h = v
|
||||
for j in range(64):
|
||||
t = 0x79CC4519 if j < 16 else 0x7A879D8A
|
||||
ss1 = _rotl((_rotl(a, 12) + e + _rotl(t, j)) & _MASK, 7)
|
||||
ss2 = ss1 ^ _rotl(a, 12)
|
||||
if j < 16:
|
||||
ff, gg = a ^ b ^ c, e ^ f ^ g
|
||||
else:
|
||||
ff, gg = (a & b) | (a & c) | (b & c), (e & f) | (~e & g)
|
||||
tt1 = (ff + d + ss2 + w1[j]) & _MASK
|
||||
tt2 = (gg + h + ss1 + w[j]) & _MASK
|
||||
a, b, c, d, e, f, g, h = tt1, a, _rotl(b, 9), c, _p0(tt2), e, _rotl(f, 19), g
|
||||
v = [x ^ y for x, y in zip(v, (a, b, c, d, e, f, g, h), strict=True)]
|
||||
return struct.pack(">8I", *v)
|
||||
|
||||
|
||||
def _add(p: Point | None, q: Point | None) -> Point | None:
|
||||
if p is None:
|
||||
return q
|
||||
if q is None:
|
||||
return p
|
||||
if p[0] == q[0] and (p[1] + q[1]) % P == 0:
|
||||
return None
|
||||
numerator, denominator = (3 * p[0] * p[0] + A, 2 * p[1]) if p == q else (q[1] - p[1], q[0] - p[0])
|
||||
slope = numerator * pow(denominator, -1, P) % P
|
||||
x = (slope * slope - p[0] - q[0]) % P
|
||||
return x, (slope * (p[0] - x) - p[1]) % P
|
||||
|
||||
|
||||
def _multiply(k: int, point: Point) -> Point | None:
|
||||
result: Point | None = None
|
||||
addend: Point | None = point
|
||||
while k:
|
||||
if k & 1:
|
||||
result = _add(result, addend)
|
||||
addend = _add(addend, addend)
|
||||
k >>= 1
|
||||
return result
|
||||
|
||||
|
||||
def on_curve(point: Point) -> bool:
|
||||
x, y = point
|
||||
return 0 <= x < P and 0 <= y < P and (y * y - (x * x * x + A * x + B)) % P == 0
|
||||
|
||||
|
||||
def lift_x(x: int, *, odd: bool) -> Point | None:
|
||||
"""The curve point with this x coordinate and the requested y parity, if any."""
|
||||
if not 0 <= x < P:
|
||||
return None
|
||||
y_squared = (x * x * x + A * x + B) % P
|
||||
y = pow(y_squared, (P + 1) // 4, P) # P % 4 == 3
|
||||
if y * y % P != y_squared:
|
||||
return None
|
||||
return (x, y) if (y & 1) == odd else (x, P - y)
|
||||
|
||||
|
||||
def signer_digest(public_key: Point, message: bytes, user_id: bytes = DEFAULT_USER_ID) -> bytes:
|
||||
"""``e = SM3(Z_A || M)``, the value an SM3withSM2 signature signs."""
|
||||
identity_bits = (len(user_id) * 8).to_bytes(2, "big")
|
||||
fields = (A, B, G[0], G[1], public_key[0], public_key[1])
|
||||
z = sm3(identity_bits + user_id + b"".join(value.to_bytes(32, "big") for value in fields))
|
||||
return sm3(z + message)
|
||||
|
||||
|
||||
def verify_digest(public_key: Point, digest: bytes, r: int, s: int) -> bool:
|
||||
"""Verify an SM2 signature ``(r, s)`` over an already computed digest ``e``."""
|
||||
if not (1 <= r < N and 1 <= s < N) or not on_curve(public_key):
|
||||
return False
|
||||
t = (r + s) % N
|
||||
if t == 0:
|
||||
return False
|
||||
point = _add(_multiply(s, G), _multiply(t, public_key))
|
||||
return point is not None and (int.from_bytes(digest, "big") + point[0]) % N == r
|
||||
|
||||
|
||||
def verify(public_key: Point, message: bytes, signature: bytes, user_id: bytes = DEFAULT_USER_ID) -> bool:
|
||||
"""Verify a raw 64-byte ``r || s`` SM3withSM2 signature over ``message``."""
|
||||
if len(signature) != 64:
|
||||
return False
|
||||
r, s = int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")
|
||||
return verify_digest(public_key, signer_digest(public_key, message, user_id), r, s)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Registered TC260 producer identities, mark routing, and pinned signing keys."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tc260Producer:
|
||||
"""One producer's measured codes and independently established identity."""
|
||||
|
||||
key: str
|
||||
codes: tuple[str, ...]
|
||||
vendor: str
|
||||
image_mark: str | None = None
|
||||
video_mark: str | None = None
|
||||
video_platform: str | None = None
|
||||
signer: str | None = None
|
||||
signing_key_x: int | None = None
|
||||
image_platform: str | None = None
|
||||
|
||||
|
||||
TC260_PRODUCERS = (
|
||||
# YOYO JPEG exports; USCC independently verified in Honor's privacy statement.
|
||||
Tc260Producer("honor", ("91440300MA5G49LC9K",), "Honor", image_platform="Honor"),
|
||||
Tc260Producer("doubao", ("91110102MACQD9K640", "doubao"), "ByteDance", "doubao", "doubao", "ByteDance Doubao"),
|
||||
Tc260Producer("jimeng", ("9144030008867405X2",), "ByteDance", "jimeng"),
|
||||
Tc260Producer("qwen", ("91440101MA9Y9T4H7A",), "Alibaba", "qwen", video_platform="Alibaba Cloud Qwen"),
|
||||
# Tongyi Yunqi (Hangzhou), owned by Alibaba Cloud and Tongyi Lab, signs
|
||||
# both Wan and HappyHorse exports, not just one model.
|
||||
Tc260Producer("wan", ("91330106MA2CFLDG4R",), "Alibaba", "wan", video_platform="Alibaba Tongyi (Wan, HappyHorse)"),
|
||||
# Kling 3.0 Turbo served by Higgsfield (2026-09-24) writes the bare "kling".
|
||||
Tc260Producer("kling", ("91110108335469089C", "kling"), "Kuaishou", "kling", "kling", "Kuaishou Kling AI"),
|
||||
Tc260Producer("yuanbao", ("91440300708461136T",), "Tencent", "yuanbao"),
|
||||
Tc260Producer("runninghub", ("91340100MAEB4N8H76", "RunningHub"), "RunningHub", "runninghub"),
|
||||
Tc260Producer("baidu", ("91110000802100433B",), "Baidu", "baidu"),
|
||||
Tc260Producer("liblib", ("91110105MACJ6K1C8A",), "LiblibAI", "liblib"),
|
||||
# ShengShu's USCC, verified against the Beijing municipal list of 2025-03-03.
|
||||
Tc260Producer("vidu", ("91110108MACC4D63XF",), "ShengShu", video_mark="vidu", video_platform="ShengShu Vidu"),
|
||||
# Even-y SM2 point for the x coordinate carried by measured MiniMax exports
|
||||
# (Hailuo 2.3, H3 and H3 Max, 2026-09-29). Source: docs/research-sweep-2026-09.md.
|
||||
Tc260Producer(
|
||||
"minimax",
|
||||
("MiniMax",),
|
||||
"MiniMax",
|
||||
video_mark="hailuo",
|
||||
video_platform="MiniMax Hailuo AI",
|
||||
signer="MiniMax",
|
||||
signing_key_x=0xA0B3B0B6A0C9B0C89CAB328342AF4E8221EC5B40799CBE835AB4251F7B47E4FD,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def uscc_of(code: str) -> 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 ()
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Verify TC260 ``SecurityData`` label signatures (TC260-PG-202511A).
|
||||
|
||||
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. 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
|
||||
its ``Bindings`` entry, also verifies (docs/research-sweep-2026-09.md), but no
|
||||
verdict reads it, and the content hash inside that entry is not recomputed
|
||||
because MiniMax names no content-selection method. The key travels inside the
|
||||
file it signs, so a signature proves only integrity unless the key matches a
|
||||
pinned signer in ``TC260_SIGNING_KEYS``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
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", "unsupported"]
|
||||
|
||||
SM3_WITH_SM2 = "1.2.156.10197.1.501"
|
||||
|
||||
|
||||
def _pinned(x: int) -> sm2.Point:
|
||||
point = sm2.lift_x(x, odd=False)
|
||||
if point is None:
|
||||
raise ValueError(f"pinned TC260 key x={x:#x} is not on the SM2 curve")
|
||||
return point
|
||||
|
||||
|
||||
# Derived from the same producer rows used by metadata and visible-mark routing.
|
||||
TC260_SIGNING_KEYS: dict[str, sm2.Point] = {
|
||||
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"})
|
||||
# The producer's signed fields, in GB 45438 appendix E order ("Label" to value3).
|
||||
_PRODUCER_FIELDS = ("Label", "ContentProducer", "ProduceID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tc260Signature:
|
||||
"""Outcome of checking one label's ``SecurityData`` label signature."""
|
||||
|
||||
label: Status
|
||||
signer: str | None
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.label == "verified":
|
||||
if self.signer:
|
||||
return f"TC260 label signature verified (pinned {self.signer} key)"
|
||||
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"
|
||||
|
||||
|
||||
def _candidate_keys(key_hex: str) -> tuple[list[sm2.Point], str | None]:
|
||||
"""The points a ``KeyValue`` can denote, and the pinned signer when it matches one."""
|
||||
try:
|
||||
raw = bytes.fromhex(key_hex)
|
||||
except ValueError:
|
||||
return [], None
|
||||
if len(raw) == 65 and raw[0] == 4:
|
||||
raw = raw[1:]
|
||||
if len(raw) == 33 and raw[0] == 0:
|
||||
raw = raw[1:] # MiniMax pads a bare x coordinate with 00
|
||||
if len(raw) == 64:
|
||||
points = [(int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big"))]
|
||||
elif len(raw) == 33 and raw[0] in (2, 3):
|
||||
points = [p for p in [sm2.lift_x(int.from_bytes(raw[1:], "big"), odd=raw[0] == 3)] if p]
|
||||
elif len(raw) == 32:
|
||||
x = int.from_bytes(raw, "big") # a bare x coordinate: either y may be meant
|
||||
points = [p for p in (sm2.lift_x(x, odd=False), sm2.lift_x(x, odd=True)) if p]
|
||||
else:
|
||||
return [], None
|
||||
for signer, pinned in TC260_SIGNING_KEYS.items():
|
||||
if any(p[0] == pinned[0] for p in points):
|
||||
return [pinned], signer
|
||||
return points, None
|
||||
|
||||
|
||||
def _label_message(label: Mapping[str, str]) -> bytes:
|
||||
"""Appendix A.1: ``"Label":"1","ContentProducer":"...","ProduceID":"..."``."""
|
||||
return ",".join(
|
||||
f"{json.dumps(field)}:{json.dumps(label.get(field, ''), ensure_ascii=False)}" for field in _PRODUCER_FIELDS
|
||||
).encode()
|
||||
|
||||
|
||||
def _object(value: object) -> dict[str, Any]:
|
||||
"""``value`` as a JSON object, or empty when it is anything else."""
|
||||
return cast("dict[str, Any]", value) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _objects(value: object) -> list[dict[str, Any]]:
|
||||
"""The JSON objects in a JSON array, or none when ``value`` is not an array."""
|
||||
items = cast("list[object]", value) if isinstance(value, list) else []
|
||||
return [cast("dict[str, Any]", item) for item in items if isinstance(item, dict)]
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def _verified(point: sm2.Point, message: bytes, signature: bytes) -> bool:
|
||||
# get_ai_metadata and the identify verdict both check the same label, and
|
||||
# removal re-reads metadata; the pure-Python curve math runs once per triple.
|
||||
return sm2.verify(point, message, signature)
|
||||
|
||||
|
||||
def check_tc260_signature(label: Mapping[str, str]) -> Tc260Signature | None:
|
||||
"""Check the producer's ``SecurityData`` in ``ReservedCode1``; None when it has none."""
|
||||
try:
|
||||
parsed: object = json.loads(label.get("ReservedCode1", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
security = _object(_object(parsed).get("SecurityData"))
|
||||
if security.get("Type") != "TC260PG":
|
||||
return None
|
||||
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
|
||||
if entry.get("Type") == "PubKey" and entry.get("AlgID") == SM3_WITH_SM2
|
||||
}
|
||||
message = _label_message(label)
|
||||
results: list[bool] = []
|
||||
unsupported = False
|
||||
signer: str | None = None
|
||||
for entry in public:
|
||||
if entry.get("Type") != "DS":
|
||||
continue
|
||||
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:
|
||||
signature = bytes.fromhex(str(entry.get("Signature", "")))
|
||||
except ValueError:
|
||||
signature = b""
|
||||
verified = any(_verified(point, message, signature) for point in points)
|
||||
results.append(verified)
|
||||
if verified:
|
||||
signer = signer or key_signer
|
||||
if results:
|
||||
return Tc260Signature(label="verified" if any(results) else "failed", signer=signer)
|
||||
return Tc260Signature(label="unsupported" if unsupported else "absent", signer=None)
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ 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,
|
||||
AIGC_MARKERS,
|
||||
@@ -128,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, "
|
||||
@@ -159,6 +162,10 @@ _HF_JOB_CAVEAT = (
|
||||
"medium-confidence signal. Higgsfield adds the tag after signing, which invalidates any "
|
||||
"C2PA manifest the upstream model embedded."
|
||||
)
|
||||
_TC260_SIGNATURE_FAILED_CAVEAT = (
|
||||
"The TC260 label carries a SecurityData signature that does not verify against its own "
|
||||
"fields, so the label was altered after signing or copied from another file."
|
||||
)
|
||||
_C2PA_CLOUD_CAVEAT = (
|
||||
"The embedded C2PA manifest is absent but an XMP provenance pointer to the "
|
||||
"vendor's cloud manifest store survives, so the Content Credentials remain "
|
||||
@@ -915,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]:
|
||||
@@ -1484,8 +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)
|
||||
|
||||
@@ -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):
|
||||
@@ -1424,6 +1418,10 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
# sentence above, whose wording can change without notice.
|
||||
if producer:
|
||||
result["aigc_producer"] = producer
|
||||
from remove_ai_watermarks._internal.tc260_signature import check_tc260_signature
|
||||
|
||||
if signature := check_tc260_signature(aigc):
|
||||
result["aigc_signature"] = signature.describe()
|
||||
|
||||
app_scan = scan_head(image_path)
|
||||
app_provenance, app_generator = _app_metadata_evidence(app_scan)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
|
||||
import piexif
|
||||
@@ -225,7 +226,7 @@ def test_c2pa_reader_failure_is_partial_even_after_tolerant_cached_read(tmp_path
|
||||
|
||||
class BrokenReader:
|
||||
@staticmethod
|
||||
def try_create(path):
|
||||
def try_create(path, *, context):
|
||||
calls.append(path)
|
||||
if not serialization_failure:
|
||||
raise RuntimeError("synthetic C2PA open failure")
|
||||
@@ -265,3 +266,119 @@ def test_exif_parser_failure_is_partial(tmp_path, monkeypatch):
|
||||
assert calls
|
||||
assert record["status"] == "partial"
|
||||
assert record["issues"] == [{"stage": "decoder", "code": "collection-failed"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"relative_path",
|
||||
[
|
||||
"provenance/higgsfield-gpt-image.jpg",
|
||||
"provenance/microsoft-bing-image-creator.jpg",
|
||||
"provenance/microsoft-invismark.png",
|
||||
"provenance/microsoft-paint-invismark.png",
|
||||
"visible/microsoft/provider-original.png",
|
||||
],
|
||||
)
|
||||
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
|
||||
c2pa._manifest_json_cached.cache_clear()
|
||||
c2pa._extract_c2pa_info_cached.cache_clear()
|
||||
info = c2pa.extract_c2pa_info(source)
|
||||
assert info["c2pa_integrity"] == info["c2pa_signature"] == "valid"
|
||||
assert info["c2pa_signer_validity"] != "invalid"
|
||||
assert info["c2pa_signer_trust"] == "untrusted"
|
||||
assert "signingCredential.invalid" not in info["c2pa_validation_codes"]
|
||||
report = identify(source, check_visible=False, check_invisible=False)
|
||||
assert report.confidence == "high"
|
||||
assert report.platform is not None
|
||||
if "microsoft" in relative_path:
|
||||
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()
|
||||
c2pa._extract_c2pa_info_cached.cache_clear()
|
||||
try:
|
||||
rejected = c2pa.extract_c2pa_info(source)
|
||||
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).is_ai_generated is None
|
||||
finally:
|
||||
c2pa._manifest_json_cached.cache_clear()
|
||||
c2pa._extract_c2pa_info_cached.cache_clear()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""SM3/SM2 verification and TC260 SecurityData label signatures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks._internal import sm2, tc260_signature
|
||||
from remove_ai_watermarks._internal.tc260_signature import check_tc260_signature
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MINIMAX_VIDEOS = (
|
||||
ROOT / "data" / "fixtures" / "provenance" / "higgsfield-hailuo-2-3.mp4",
|
||||
ROOT / "data" / "fixtures" / "provenance" / "higgsfield-minimax-h3.mp4",
|
||||
)
|
||||
|
||||
# OpenSSL test/recipes/30-test_evp_data/evppkey_sm2.txt, key SM2_key1: the public
|
||||
# point of its PKCS#8 key and two "Verify" rows (digest e, DER signature r, s).
|
||||
OPENSSL_KEY = (
|
||||
0x393A2AD9E27E43ACAAAB85A19D3B85591E14406157175ADDD37BF77FF0CAF9ED,
|
||||
0x37FCF3A8B4F55C038523261FCA6636AFE6A7AFA2A1482A606F5CB3A18B2E2A1E,
|
||||
)
|
||||
OPENSSL_VERIFY = (
|
||||
(
|
||||
"D7AD397F6FFA5D4F7F11E7217F241607DC30618C236D2C09C1B9EA8FDADEE2E8",
|
||||
0xAB1DB64DE7C40EDBDE6651C9B8EBDB804673DB836E5D5C7FE15DCF9ED2725037,
|
||||
0xEBA714451FF69B0BB930B379E192E7CD5FA6E3C41C7FBD8303B799AB54A54621,
|
||||
),
|
||||
(
|
||||
"B1139602C6ECC9E15E2F3F9C635A1AFE737058BC15387479C1EA0D0B3D90E9E5",
|
||||
0xE6E0414EBD3A656C35602AF14AB20287DBF30D57AF75C49A188ED4B42391F224,
|
||||
0x2F54F277C606F4605E1CE9514947FFDDF94C67A539804A4ED17F852288BDBE2E,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _minimax_label() -> dict[str, str]:
|
||||
label = aigc_label(MINIMAX_VIDEOS[0])
|
||||
assert label is not None
|
||||
return label
|
||||
|
||||
|
||||
def _with_security(label: dict[str, str], edit: object) -> dict[str, str]:
|
||||
security = json.loads(label["ReservedCode1"])
|
||||
edit(security["SecurityData"]) # type: ignore[operator]
|
||||
return {**label, "ReservedCode1": json.dumps(security, separators=(",", ":"))}
|
||||
|
||||
|
||||
class TestSm3:
|
||||
@pytest.mark.parametrize(
|
||||
("message", "digest"),
|
||||
[
|
||||
(b"abc", "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0"),
|
||||
(b"abcd" * 16, "debe9ff92275b8a138604889c18e5a4d6fdb70e5387e5765293dcba39c0c5732"),
|
||||
],
|
||||
)
|
||||
def test_standard_vectors(self, message: bytes, digest: str) -> None:
|
||||
# GB/T 32905-2016 appendix A examples 1 and 2.
|
||||
assert sm2.sm3(message).hex() == digest
|
||||
|
||||
|
||||
class TestSm2:
|
||||
@pytest.mark.parametrize(("digest", "r", "s"), OPENSSL_VERIFY)
|
||||
def test_openssl_vectors_verify(self, digest: str, r: int, s: int) -> None:
|
||||
assert sm2.verify_digest(OPENSSL_KEY, bytes.fromhex(digest), r, s)
|
||||
|
||||
@pytest.mark.parametrize(("digest", "r", "s"), OPENSSL_VERIFY)
|
||||
def test_a_changed_digest_or_signature_fails(self, digest: str, r: int, s: int) -> None:
|
||||
changed = bytes.fromhex(digest)[:-1] + b"\x00"
|
||||
assert not sm2.verify_digest(OPENSSL_KEY, changed, r, s)
|
||||
assert not sm2.verify_digest(OPENSSL_KEY, bytes.fromhex(digest), r, s ^ 1)
|
||||
|
||||
def test_lift_x_returns_the_requested_parity(self) -> None:
|
||||
for odd in (False, True):
|
||||
point = sm2.lift_x(OPENSSL_KEY[0], odd=odd)
|
||||
assert point is not None
|
||||
assert sm2.on_curve(point)
|
||||
assert (point[1] & 1) == odd
|
||||
assert OPENSSL_KEY in {sm2.lift_x(OPENSSL_KEY[0], odd=False), sm2.lift_x(OPENSSL_KEY[0], odd=True)}
|
||||
|
||||
def test_an_off_curve_key_is_rejected(self) -> None:
|
||||
digest, r, s = OPENSSL_VERIFY[0]
|
||||
assert not sm2.verify_digest((OPENSSL_KEY[0], OPENSSL_KEY[1] + 1), bytes.fromhex(digest), r, s)
|
||||
# Congruent to the real key mod P, so the point arithmetic alone would accept it.
|
||||
assert not sm2.verify_digest((OPENSSL_KEY[0] + sm2.P, OPENSSL_KEY[1]), bytes.fromhex(digest), r, s)
|
||||
|
||||
|
||||
class TestTc260Signature:
|
||||
@pytest.mark.parametrize("path", MINIMAX_VIDEOS, ids=lambda p: p.name)
|
||||
def test_real_minimax_labels_verify_against_the_pinned_key(self, path: Path) -> None:
|
||||
label = aigc_label(path)
|
||||
assert label is not None
|
||||
result = check_tc260_signature(label)
|
||||
assert result == tc260_signature.Tc260Signature(label="verified", signer="MiniMax")
|
||||
assert result.describe() == "TC260 label signature verified (pinned MiniMax key)"
|
||||
|
||||
def test_a_changed_produce_id_fails_the_label_signature(self) -> None:
|
||||
label = _minimax_label()
|
||||
produce_id = label["ProduceID"]
|
||||
altered = {**label, "ProduceID": produce_id[:-1] + ("0" if produce_id[-1] != "0" else "1")}
|
||||
result = check_tc260_signature(altered)
|
||||
assert result is not None
|
||||
assert (result.label, result.signer) == ("failed", None)
|
||||
|
||||
def test_a_repeated_check_verifies_once(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[bytes] = []
|
||||
real_verify = sm2.verify
|
||||
|
||||
def counting(point: sm2.Point, message: bytes, signature: bytes) -> bool:
|
||||
calls.append(message)
|
||||
return real_verify(point, message, signature)
|
||||
|
||||
tc260_signature._verified.cache_clear()
|
||||
monkeypatch.setattr(sm2, "verify", counting)
|
||||
label = _minimax_label()
|
||||
first, second = check_tc260_signature(label), check_tc260_signature(dict(label))
|
||||
assert first == second
|
||||
assert first is not None
|
||||
assert first.label == "verified"
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_an_unpinned_key_proves_integrity_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(tc260_signature, "TC260_SIGNING_KEYS", {})
|
||||
result = check_tc260_signature(_minimax_label())
|
||||
assert result is not None
|
||||
assert (result.label, result.signer) == ("verified", None)
|
||||
assert "no pinned signer" in result.describe()
|
||||
|
||||
def test_a_pinned_signer_is_checked_with_the_pinned_point(self) -> None:
|
||||
# The file's own parity claim is untrusted: an odd-y spelling of MiniMax's
|
||||
# x coordinate still verifies, because the pinned even-y point is used.
|
||||
def edit(security: dict[str, object]) -> None:
|
||||
for entry in security["PubSD"]: # type: ignore[union-attr]
|
||||
if entry["Type"] == "PubKey":
|
||||
entry["KeyValue"] = "03" + entry["KeyValue"][-64:]
|
||||
|
||||
result = check_tc260_signature(_with_security(_minimax_label(), edit))
|
||||
assert result is not None
|
||||
assert (result.label, result.signer) == ("verified", "MiniMax")
|
||||
|
||||
def test_the_first_key_with_an_id_wins(self) -> None:
|
||||
def edit(security: dict[str, object]) -> None:
|
||||
foreign = {
|
||||
"Type": "PubKey",
|
||||
"AlgID": tc260_signature.SM3_WITH_SM2,
|
||||
"KeyValue": "04" + f"{OPENSSL_KEY[0]:064x}{OPENSSL_KEY[1]:064x}",
|
||||
}
|
||||
security["PubSD"].append(foreign) # type: ignore[union-attr]
|
||||
|
||||
result = check_tc260_signature(_with_security(_minimax_label(), edit))
|
||||
assert result is not None
|
||||
assert (result.label, result.signer) == ("verified", "MiniMax")
|
||||
|
||||
def test_a_foreign_key_does_not_verify_the_label(self) -> None:
|
||||
def edit(security: dict[str, object]) -> None:
|
||||
for entry in security["PubSD"]: # type: ignore[union-attr]
|
||||
if entry["Type"] == "PubKey":
|
||||
entry["KeyValue"] = "04" + f"{OPENSSL_KEY[0]:064x}{OPENSSL_KEY[1]:064x}"
|
||||
|
||||
result = check_tc260_signature(_with_security(_minimax_label(), edit))
|
||||
assert result is not None
|
||||
assert (result.label, result.signer) == ("failed", None)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reserved",
|
||||
[
|
||||
"",
|
||||
"S7Wr/Evf+Mq+T3SWymUoO7naAs0WPNLrahOqG9WzR+o=",
|
||||
'{"SecurityData":{"Type":"OTHER","Version":1}}',
|
||||
"[1, 2]",
|
||||
],
|
||||
)
|
||||
def test_reserved_codes_without_security_data_are_not_signatures(self, reserved: str) -> None:
|
||||
assert check_tc260_signature({"Label": "1", "ReservedCode1": reserved}) is None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestIdentifyIntegration:
|
||||
def test_a_verified_pinned_signature_is_a_high_confidence_signal(self, tmp_path: Path) -> None:
|
||||
from remove_ai_watermarks.identify import identify
|
||||
|
||||
report = identify(
|
||||
_labeled_png(tmp_path / "signed.png", _minimax_label()), check_visible=False, check_invisible=False
|
||||
)
|
||||
signatures = [signal for signal in report.signals if signal.name == "aigc_signature"]
|
||||
assert [(signal.detail, signal.confidence) for signal in signatures] == [
|
||||
("TC260 label signature verified (pinned MiniMax key)", "high")
|
||||
]
|
||||
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": "<x:Iptc4xmpExt:AISystemUsed>OpenAI</x:Iptc4xmpExt:AISystemUsed>"},
|
||||
)
|
||||
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
|
||||
|
||||
label = _minimax_label()
|
||||
altered = {**label, "ProduceID": "0" * len(label["ProduceID"])}
|
||||
report = identify(_labeled_png(tmp_path / "altered.png", altered), check_visible=False, check_invisible=False)
|
||||
assert not any(signal.name == "aigc_signature" for signal in report.signals)
|
||||
assert any("does not verify" in caveat for caveat in report.caveats)
|
||||
|
||||
def test_the_metadata_record_path_matches_the_file_path(self, tmp_path: Path) -> None:
|
||||
from remove_ai_watermarks.identify import identify, identify_metadata_record
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata
|
||||
from remove_ai_watermarks.metadata_record import collect_metadata_record
|
||||
|
||||
path = _labeled_png(tmp_path / "signed.png", _minimax_label())
|
||||
record = json.loads(json.dumps(collect_metadata_record(path)))
|
||||
from_record = identify_metadata_record(record, path=path)
|
||||
from_file = identify(path, check_visible=False, check_invisible=False)
|
||||
assert from_record.signals == from_file.signals
|
||||
assert from_record.caveats == from_file.caveats
|
||||
assert get_ai_metadata(path)["aigc_signature"] == "TC260 label signature verified (pinned MiniMax key)"
|
||||
|
||||
def test_a_minimax_video_carries_the_signature_marker(self) -> None:
|
||||
from remove_ai_watermarks.video import identify_video
|
||||
|
||||
report = identify_video(MINIMAX_VIDEOS[0], check_visible=False)
|
||||
assert report.metadata_markers["aigc_signature"] == "TC260 label signature verified (pinned MiniMax key)"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__]))
|
||||
@@ -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})
|
||||
@@ -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"<TC260:AIGC>" + json.dumps(payload).encode() + b"</TC260:AIGC>"
|
||||
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"
|
||||
Reference in new issue
Block a user