Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@ explicitly: the **rulepack format** — what a rulepack file may contain — and

### Added

- **`MPF-M-002` verifies the C2PA manifest bound to a delivered document** (#18),
through a new `document` probe. C2PA 2.4 §A.7 binds a manifest to a document
that cannot embed one — HTML above all — by hashing the delivered bytes, and the
document points at it through an RFC 8288 `Link:` header or a
`<link rel="c2pa-manifest">` element.

This is the delivery-chain regression the project exists for, in the format most
likely to suffer it: a minifier, an HTML-rewriting CDN or a template change
turns a valid provenance claim into an invalid one while the page still renders
perfectly. Nothing errors and no log line appears.

The probe fetches the bytes the server sent rather than driving a browser — a
browser normalises markup, and only the delivered bytes are what the manifest
signs. It refuses a manifest on another origin: a provenance claim that depends
on a third party being reachable stops being checkable when they are not, and a
dangling manifest link is worse than none, because it reads as marked and
verifies as nothing.

- **`markproof init`** writes a starting `markproof.yaml`. The CLI's own docstring
had promised this command since M4 and the build did not have it. The scaffold
configures one chat probe and leaves media, UI and text marking commented out
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ target:
type: ui
url: https://example.com/blog/latest
content_selector: "article .body" # the model-written text, nothing else
- id: article-provenance
type: document # the delivered bytes, for the C2PA binding
url: https://example.com/blog/latest
applicability:
deepfake-labelling: false # this target generates no deep fakes
text_marking:
Expand Down
13 changes: 8 additions & 5 deletions docs/DISCLAIMER.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,14 @@ than none:
about a narrow test, not a defence.
- **Text marking on a web page is checked in one place only.** For a rendered
document markproof scores the region named by `content_selector` against the
operator's watermark configuration, and nothing else. It does **not** follow
the C2PA binding for HTML documents (C2PA Technical Specification 2.4, §A.7,
April 2026) or the `c2pa.ai-disclosure` assertion (§18.28). Those exist and are
checkable; markproof has not implemented them yet. A green run on a web page
therefore says the watermark survived, not that the document is marked.
operator's watermark configuration, and nothing else — a `ui` probe reports what
a browser rendered, which is not the bytes a C2PA manifest signs.
- **Document provenance is a separate probe.** `MPF-M-002` verifies the C2PA
binding for a delivered document (C2PA Technical Specification 2.4, §A.7) via
the `document` probe, which fetches the bytes the server sent and resolves the
manifest from a `Link:` header or a `<link rel="c2pa-manifest">` element. It
refuses a manifest hosted on another origin: a provenance claim that depends on
a third party being reachable stops being checkable when they are not.
- **Marking is checked, detectability is not.** Article 50(2) requires that
outputs be marked *and* detectable as artificially generated. markproof
measures the first limb against your own configuration. Whether a third party
Expand Down
18 changes: 17 additions & 1 deletion docs/RULES_SOURCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,23 @@ Konvention, deren Fehlen hier behauptet wurde — geprüft am 31.08.2026 gegen

Der Satz, der Bestand hat, ist der engere: markproof erfindet keine eigene
`<meta>`-Konvention. Gegen eine fremde, veröffentlichte zu prüfen, ist dagegen
genau die Aufgabe. Die Regel dafür ist offen (Issue #18).
genau die Aufgabe — und seit dem 01.09.2026 tut `MPF-M-002` das.

**Nachtrag zur Werkzeuglage.** Hier stand zunächst, `c2pa-rs` habe keinen
HTML-Handler und die einzige A.7-Implementierung sei von dritter Seite. Das war zu
weit gefasst. Richtig ist die engere Aussage: `c2pa-rs` kann ein Manifest **nicht
in HTML einbetten** — aber es kann eines über HTML-Bytes erzeugen und prüfen,
sobald es *abgesetzt* geführt wird (`set_no_embed`, und beim Lesen
`manifest_data`). Genau das ist die A.7-Anordnung, und genau darauf steht
`MPF-M-002`. Am 01.09.2026 gegen `c2pa-python` 0.37.8 / `c2pa-rs` 0.90.15 geprüft:
Ein Dokument, das nach dem Signieren um vier Zeichen geändert wurde, meldet
`assertion.dataHash.mismatch`; das unveränderte meldet ihn nicht.

Die Regel prüft deshalb die **ausgelieferten Bytes** und nicht die gerenderte
Seite. Ein Browser normalisiert Markup, bevor irgendetwas lesbar ist; die Bindung
deckt aber, was der Server gesendet hat. Das ist zugleich der Grund, warum die
Prüfung überhaupt lohnt: Ein Minifier oder ein HTML-umschreibendes CDN zerstört
die Bindung, während die Seite perfekt aussieht.

Was allgemein *nicht* existiert, ist eine Konvention außerhalb von C2PA: Der
WHATWG-Vorschlag für ein Meta-Tag (#9479, offen seit dem 02.07.2023) wartet
Expand Down
37 changes: 34 additions & 3 deletions src/markproof/checks/c2pa_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,22 +178,53 @@ def verify_media(
check: C2paVerifyCheck,
*,
artifact_id: str,
sidecar_manifest: bytes | None = None,
) -> C2paResult:
"""Verify one media payload against a rule's C2PA requirements.
"""Verify one payload against a rule's C2PA requirements.

Pure with respect to the network: the bytes are already in hand, and remote
manifests are refused rather than fetched, so the same payload always yields
the same verdict.

``sidecar_manifest`` carries a manifest that travels beside the bytes instead
of inside them, which is how C2PA binds provenance to formats that cannot
embed one — HTML being the case that matters. The binding is still a hash over
the delivered bytes, so it is verified exactly as strictly: a document altered
after signing fails with the same ``dataHash`` mismatch an altered JPEG does.
"""
import c2pa

try:
reader = c2pa.Reader(media_type, stream=io.BytesIO(data))
reader = c2pa.Reader(media_type, stream=io.BytesIO(data), manifest_data=sidecar_manifest)
except c2pa.C2paError.ManifestNotFound:
return C2paResult(
outcome=C2paOutcome.MANIFEST_MISSING,
artifact_id=artifact_id,
detail="no C2PA manifest embedded in the delivered bytes",
detail=(
"no C2PA manifest accompanies the delivered bytes"
if sidecar_manifest is not None
else "no C2PA manifest embedded in the delivered bytes"
),
)
except c2pa.C2paError.NotSupported:
if sidecar_manifest is None:
# Not an unreadable payload — a definite absence. The format cannot
# carry an embedded manifest at all (HTML is the case that matters),
# and none travelled beside it, so there is nowhere a manifest could
# be. Saying "unreadable" here would suggest the evidence was at
# fault when the finding is about the asset.
return C2paResult(
outcome=C2paOutcome.MANIFEST_MISSING,
artifact_id=artifact_id,
detail=(
f"{media_type} cannot carry an embedded manifest and none accompanied "
'it — no Link header and no <link rel="c2pa-manifest"> in the document'
),
)
return C2paResult(
outcome=C2paOutcome.UNREADABLE,
artifact_id=artifact_id,
detail=f"a manifest was supplied but {media_type} could not be read alongside it",
)
except c2pa.C2paError as exc:
# Includes truncated payloads and formats the SDK cannot parse. Not the
Expand Down
4 changes: 4 additions & 0 deletions src/markproof/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from markproof.checks.synthid import WatermarkConfig, load_watermark_config
from markproof.config import (
ConfigError,
DocumentProbeConfig,
HttpChatProbeConfig,
MarkproofConfig,
MediaProbeConfig,
Expand All @@ -47,6 +48,7 @@
load_config,
)
from markproof.probes.base import Evidence, ProbeError
from markproof.probes.document import DocumentProbe
from markproof.probes.http_chat import HttpChatProbe
from markproof.probes.media import MediaProbe
from markproof.probes.ui import UiProbe
Expand Down Expand Up @@ -250,6 +252,8 @@ def _collect(config: MarkproofConfig) -> tuple[list[Evidence], list[Finding]]:
# kind this build does not know as a chat probe.
if isinstance(probe_config, MediaProbeConfig):
evidences.append(MediaProbe(probe_config).collect())
elif isinstance(probe_config, DocumentProbeConfig):
evidences.append(DocumentProbe(probe_config).collect())
elif isinstance(probe_config, UiProbeConfig):
evidences.append(UiProbe(probe_config).collect())
elif isinstance(probe_config, HttpChatProbeConfig):
Expand Down
52 changes: 51 additions & 1 deletion src/markproof/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"Applicability",
"AuthConfig",
"ConfigError",
"DocumentProbeConfig",
"HttpChatProbeConfig",
"MarkproofConfig",
"MediaProbeConfig",
Expand Down Expand Up @@ -283,10 +284,59 @@ def _supported_lang(cls, v: str) -> str:
return v


class DocumentProbeConfig(BaseModel):
"""A document to fetch as bytes and check for provenance.

Deliberately not the UI probe. A C2PA binding is a hash over what the server
sent; a browser normalises markup before anything is readable, so the rendered
document and the delivered one are different bytes and only one of them is
what the manifest signs. Fetching plainly is both more faithful and cheaper —
no browser, no extra.
"""

model_config = ConfigDict(extra="forbid", frozen=True)

id: str = Field(min_length=1)
type: Literal["document"]
url: str = Field(min_length=1)
lang: str = "de"
auth: AuthConfig | None = None
timeout_seconds: float = Field(default=30.0, gt=0, le=300)
prompt_id: str = Field(default="document-fetch", min_length=1)

max_bytes: int = Field(default=8 * 1024 * 1024, ge=1024)
"""Refuse to hash a document larger than this.

A provenance check reads the whole body into memory, so an endpoint that
answers with a stream would otherwise decide how much memory this process
uses. Eight megabytes is far past any HTML document and far short of a
problem.
"""

@property
def probe_kind(self) -> ProbeKind:
return ProbeKind.DOCUMENT

@field_validator("url")
@classmethod
def _http_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://")):
raise ValueError("url must start with http:// or https://")
return v

@field_validator("lang")
@classmethod
def _supported_lang(cls, v: str) -> str:
if v not in SUPPORTED_LANGS:
raise ValueError(f"lang {v!r} is not supported (have: {', '.join(SUPPORTED_LANGS)})")
return v


#: A probe entry in the config. Discriminated on ``type`` so an unknown probe
#: kind is a loud config error rather than a silently ignored block.
ProbeConfig = Annotated[
HttpChatProbeConfig | UiProbeConfig | MediaProbeConfig, Field(discriminator="type")
HttpChatProbeConfig | UiProbeConfig | MediaProbeConfig | DocumentProbeConfig,
Field(discriminator="type"),
]


Expand Down
34 changes: 33 additions & 1 deletion src/markproof/probes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,39 @@ class Artifact(BaseModel):
into the artefacts directory.
"""

sidecar_manifest: bytes | None = Field(default=None, exclude=True, repr=False)
"""A C2PA manifest that travels *beside* these bytes rather than inside them.

Some formats cannot carry an embedded manifest — HTML is the one that matters
here — so C2PA binds an external manifest to the document by hashing it, and
points at the manifest from a ``<link rel="c2pa-manifest">`` element or an
RFC 8288 ``Link:`` response header. The bytes below are then exactly what the
server sent, which is the point: the binding covers them, so anything that
rewrites them on the way — a minifier, an HTML-transforming CDN — invalidates
the provenance claim while the page still renders perfectly.

Excluded from serialisation for the same reason as ``data``.
"""

sidecar_source: str | None = None
"""How the sidecar manifest was found — ``link-header`` or ``link-element``.

Kept because the two are not equivalent in practice: a header survives an HTML
rewrite that would strip or move the element, so a report saying which one the
delivery chain actually used tells an operator something they cannot see from
a pass alone.
"""

@classmethod
def of(
cls, data: bytes, *, artifact_id: str, media_type: str, source_url: str | None = None
cls,
data: bytes,
*,
artifact_id: str,
media_type: str,
source_url: str | None = None,
sidecar_manifest: bytes | None = None,
sidecar_source: str | None = None,
) -> Artifact:
"""Build an artefact from payload bytes, computing size and digest."""
return cls(
Expand All @@ -102,6 +132,8 @@ def of(
sha256=sha256_hex(data),
source_url=source_url,
data=data,
sidecar_manifest=sidecar_manifest,
sidecar_source=sidecar_source,
)


Expand Down
Loading
Loading