c2patxt
Embed, extract and verify C2PA Content Credentials in plain Unicode text.
Implements C2PA Technical Specification 2.4 (2026-04-01), HTML build c7e55d5a,
Annex A.8 "Embedding Manifests into Unstructured Text" — a manifest store encoded as
Unicode variation selectors, appended after the visible text. Marked text renders
exactly as the original: the mark is zero-width.
This is Duale AI's implementation of C2PA text marking. It is not a C2PA consortium release and carries no conformance certification.
Why this exists
EU AI Act Article 50(2) obliges providers of generative AI systems to mark synthetic content in a machine-readable form and make it detectable as artificially generated, "as far as this is technically feasible" — and it exempts systems performing an assistive function for standard editing, systems that do not substantially alter the input or its semantics, and use authorised by law for law enforcement. It applies from 2 August 2026 (Reg. (EU) 2024/1689, Art. 113). Systems placed on the market before that date get a four-month transitional period, to 2 December 2026 (Art. 111(4), as inserted by Reg. (EU) 2026/1744) — a deferral, not an exemption.
The Commission's Guidelines on Article 50 (C(2026) 5054 final, 20 July 2026), para (76), say providers "must rely on publicly-available industry standard detection solutions that allow any third party to implement detection … Where such standards are not available … the provider may rely on its own detection solution". That is the design brief here, and the escape hatch is why: the format is a published specification, the vectors are CC0, and verification needs this package or any other A.8 implementation — not us.
Guidelines under Art. 96 do not bind, and these are not yet formally adopted: the accompanying Communication says they apply only once adopted in all language versions. Read them as the Commission's stated expectation, not as law.
What this does not do. It does not make anyone compliant, and no standard is mandated: the Code of Practice on Transparency of AI-generated Content (10 June 2026) names no marking or detection standard, and mentions neither C2PA nor Content Credentials. Marking is one obligation among several in Article 50, and this package implements marking for text. Whether your deployment satisfies the Article is a question for your counsel, not for a library.
$ pip install c2patxt
Python 3.10+. One runtime dependency: cryptography.
Or from a checkout:
$ git clone https://github.com/dualeai/c2patxt && cd c2patxt
$ make install
Five minutes
import datetime
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.x509.oid import NameOID
from c2patxt import (
C2PA_CLAIM_SIGNING_EKU,
Disclosure,
ModelType,
Provenance,
Signer,
embed,
verify,
)
def build_leaf(key):
"""A conformant self-signed leaf. Self-signed verifies as VALID (untrusted)."""
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "your signing key")])
start = datetime.datetime.now(tz=datetime.timezone.utc)
end = start + datetime.timedelta(days=365)
return (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(start)
.not_valid_after(end)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
# Omit this line entirely and Signer refuses to construct (14.5.1.1).
.add_extension(x509.ExtendedKeyUsage([C2PA_CLAIM_SIGNING_EKU]), critical=False)
.sign(key, None) # None: Ed25519 prehashes internally (RFC 8032)
)
key = ed25519.Ed25519PrivateKey.generate()
leaf = build_leaf(key)
signer = Signer(private_key=key, certificates=(leaf,))
disclosure = Disclosure(media_type="text/plain", model_type=ModelType.GENERIC)
marked = embed("Text your model produced.", signer, disclosure)
# renders identically to the input; the mark is zero-width
result = verify(marked)
match result.state:
case Provenance.TRUSTED:
... # verifies AND chains to an anchor you supplied
case Provenance.VALID:
... # intact and signed; signer not corroborated
case Provenance.INVALID:
... # a mark is present and failed validation
case Provenance.UNMARKED:
... # no mark. NOT a finding about the text.
strip(text) removes a mark. extract(text) returns the manifest without validating
it. locate(text) returns the wrapper's byte span without decoding the manifest.
Those five are the working surface. __all__ exports thirty names in all — the rest
are the exception types, the two context objects that buy determinism (EmbedContext,
VerifyContext), the verdict and status types, the three allocation bounds, and the
version strings. Read c2patxt.__all__ for the list; anything not in it is private and
may change without notice.
strip(embed(x)) is not always x. embed normalizes to NFC before marking,
because the hard binding is defined over the NFC form; strip only removes the
wrapper. So for text that was not already NFC, the round trip returns the NFC form of
what you passed in. Both halves are correct and the asymmetry is the surprising part.
The certificate is the part people get wrong
The leaf must carry an EKU extension, present and non-empty (C2PA 14.5.1.1), must
not assert cA or keyCertSign, and must assert digitalSignature. Miss any of those
and every verifier — including this one — rejects it as signingCredential.invalid.
Signer refuses a non-conformant certificate at construction, so you find out now
rather than after the bytes ship.
The c2pa-kp-claimSigning OID itself is not required by 14.5.1.1, which names no
claim-signing OID — the OIDs it does name are anyExtendedKeyUsage (forbidden),
id-kp-timeStamping and id-kp-OCSPSigning. Include it anyway: 14.5.1.2 says a
validator shall use only the trust anchors it associates with EKUs present in the
certificate, so a credential carrying no EKU a trust store recognises cannot chain,
whatever else is right about it. A leaf carrying only id-kp-emailProtection verifies
here as VALID (untrusted).
The builder is in Five minutes above, annotated. Every extension
there is one of the four rules in this section; drop any of them and Signer
refuses to construct.
C2PA_CLAIM_SIGNING_EKU is 1.3.6.1.4.1.62558.2.1. You need the number, not the name,
if you mint the leaf with OpenSSL or a CA rather than with the code above.
What this proves, and what it does not
Proves. That the text was marked by the holder of a specific signing key, that it declares itself machine-generated, and that not one byte of the covered text has changed since. A cryptographic statement, not a probabilistic one.
Does not prove. That the content is accurate, that the claims in it are true, or who the signer is in the world — that last one depends entirely on which trust anchors you supply.
Absence of a mark proves nothing. Most text ever written is unmarked. Human text is unmarked. Text from a model that does not mark is unmarked. Text whose mark was stripped is unmarked. This is the DKIM lesson: unsigned mail is not forged mail, and treating it as such is the most damaging thing you can do with this library.
Rendering the verdict
There is no CLI and no UI here. Your interface is the only place this result reaches a human.
Verdict is deliberately not boolean-convertible. bool(verdict) raises
TypeError, because "CLEAN" if verdict else "FAKE" would render unmarked text as
forged, and a frozen dataclass is always truthy.
# WRONG — every one of these ships a lie
if verdict:
... # raises TypeError, by design
"AI-generated" if verdict.manifest else "Human" # present whenever the manifest PARSED
StatusCode.CLAIM_SIGNATURE_VALIDATED in verdict.codes() # TRUE on tampered text
# RIGHT — name your threshold
if verdict.at_least(Provenance.VALID): # -> bool
...
verdict.raise_for_state(Provenance.VALID) # -> None, or raises ValueError
verdict.codes() returns tuple[StatusCode, ...] across all three buckets.
That third wrong line is the subtle one. claimSignature.validated is genuinely
present on a document whose text was rewritten — the signature over the claim really
is intact; only its binding to the text broke.
The second line is wrong in both directions, which is why it is not a usable test.
verdict.manifest is present whenever the manifest parsed, so it is there on most
failures — but it is None for unmarked text, a corrupt wrapper, more than one
wrapper, and any structural failure inside the manifest. verdict.span is None in
those cases too. Reaching straight for verdict.manifest.assertions raises
AttributeError on exactly the hostile inputs where you most need it not to.
| State | Say | Never say |
|---|---|---|
TRUSTED |
"Signed by «name», verified against your trust list" | — |
VALID |
"Declares AI generation; signer not independently verified" | "Unverified", "Suspicious" |
INVALID |
"Carries a credential that failed validation — may have been edited" | "Fake" |
UNMARKED |
"No credential present" | "Human-written", "Fake", "Failed" |
VALID carrying signingCredential.untrusted is the normal, correct outcome for a
self-signed credential, and this package ships zero trust anchors. C2PA 14.3.5
defines a Valid manifest without requiring trust; 14.3.6 adds trust separately. To
reach TRUSTED, supply both through VerifyContext — anchors as a PEM bundle, and an
evaluator that decides whether a chain reaches one:
from c2patxt import VerifyContext, verify
verdict = verify(
marked,
context=VerifyContext(
anchors_pem=pathlib.Path("anchors.pem").read_bytes(),
trust_evaluator=your_evaluator, # see TrustEvaluator; the default trusts nothing
),
)
anchors_pem is the only channel: nothing is read from the environment, from a
bundled store or from disk on its own. VerifyContext(now=...) fixes the instant
validity is judged against and must be timezone-aware — a naive datetime raises
ValueError at construction rather than deep inside verification.
verdict.failure is not empty on a good mark
The codes are bucketed three ways — verdict.success, verdict.failure,
verdict.informational. Every mark this package produces puts one entry in
failure, because a self-signed credential cannot be corroborated:
state : valid
success : assertion.hashedURI.match, assertion.dataHash.match,
claimSignature.validated, claimSignature.insideValidity
failure : signingCredential.untrusted
So if verdict.failure: is a fourth wrong line, and the most tempting one — it reads
like exactly the check you want and paints a red error on the normal outcome. state
is the answer to "is this good". The buckets say which rules were evaluated and how
each came out — signingCredential.untrusted is a finding, not a fault, for the reason
given above.
Reaching TRUSTED needs VerifyContext(anchors_pem=...) and a trust_evaluator.
anchors= is not a keyword you can pass: it is derived, and passing it is a
TypeError.
Three of the four functions raise
verify is total: absent, corrupt and invalid marks are all Verdicts. The other
three are not. On a malformed wrapper — the 13-byte header declaring a 4 GiB manifest
from Limits — the split is:
verify -> Verdict(state=invalid)
extract -> raises MarkCorruptError
strip -> raises MarkCorruptError
locate -> raises MarkCorruptError
Catch C2paTextError; MarkCorruptError, AlreadyMarkedError,
UnencodableTextError and ProfileError all derive from it. ProfileError is the one
most integrators meet first — it is what Signer(...) raises for a non-conformant
certificate. Catch the base class rather than the four subclasses; the list can grow. The reason this matters on a verification endpoint is in
SECURITY.md. The same
split applies when a limit trips — verify returns INVALID, the other three raise.
Limits
| Property | Answer |
|---|---|
| Survives copy-paste of the full text | Yes, where the application preserves variation selectors |
| Survives any edit to the visible text | No. By design — see robustness |
| Survives NFC/NFD/NFKC/NFKD | The mark does; the binding does not. Decomposing is enough — see below |
| Detects tampering | Yes; that is the mechanism |
| Identifies the signer | Only against anchors you supply |
| Network access | None, ever. No revocation fetch, no OCSP, no timestamp authority |
| Determinism | Only with VerifyContext(now=...). By default verify reads the clock — see below |
| Signature algorithm | Ed25519 only — our narrowing; 13.2.1 also allows ES256/384/512 and PS256/384/512 |
| Size cost | 3.90 UTF-8 bytes per manifest byte, measured; 7,001 B per mark under a pinned context, 7,153–7,161 B with a real UUID and clock. The spread is the DER length of a random serial number, and the figure moves with your certificate |
| Thread safety | Safe to share. Every public type is a frozen dataclass, the digest cache is per-call, and there is no module-level mutable state. A Signer wraps a cryptography Ed25519PrivateKey, whose signing operation is safe to call from multiple threads |
| Maximum input length | None, deliberately — body-size limiting is yours. See below |
Decomposing a marked document breaks it, with nothing visibly edited. The mark survives — variation selectors have no decomposition — but the binding covers the bytes, and NFD rewrites them:
NFC form -> valid
NFD form -> invalid assertion.dataHash.malformed
The two are canonically equivalent and render identically. Any transport that
normalizes — macOS filenames, some CMSes, some Java stacks — will do this to a document
nobody edited. Note the code is malformed, not mismatch: the wrapper moved, so the
declared exclusion no longer names it.
A verdict has a shelf life. C2PA 15.8 judges certificate validity at validation time, not signing time, so the same bytes give different answers as the leaf expires:
from c2patxt import VerifyContext
inside = leaf.not_valid_after_utc - datetime.timedelta(days=1)
after = leaf.not_valid_after_utc + datetime.timedelta(days=1)
verify(marked, context=VerifyContext(now=inside)).state # Provenance.VALID
verify(marked, context=VerifyContext(now=after)).state # Provenance.INVALID
The second carries claimSignature.outsideValidity. Nothing was tampered with; the
credential simply expired between the two calls.
Pass VerifyContext(now=...) to fix the instant and make verify a pure function of
its arguments — which is what makes a stored verdict reproducible. Without it, do not
cache one and treat it as permanent.
Resource limits
Verification allocates in proportion to its input and refuses to grow past three bounds, all of which are ours rather than the specification's:
| Bound | Value | What it stops |
|---|---|---|
MAX_MANIFEST_LENGTH |
2 MiB | A 13-byte header declaring a 4 GiB manifest |
MAX_SELECTOR_RUN |
2 MiB + 13 | Walking an unbounded run of variation selectors |
MAX_JUMBF_DEPTH |
32 | Unbounded recursion in JUMBF and in CBOR |
There is no limit on the length of the text you pass in, and that is deliberate — this library cannot know what your endpoint considers a reasonable request. Budget roughly 0.1 ms of CPU per MB of unmarked text and about 4x the document size in peak memory for a marked one, and cap the body size at your edge.
Per-attack survival rates are in docs/robustness.md (PAN'26 corpus, 300 documents, CC-BY-4.0, DOI 10.5281/zenodo.18620130). Read both columns: carrier survival is not provenance survival. A transform can leave every selector intact, so the mark is still found, while the covered bytes changed and the binding correctly fails. Read the notes beside the rows before quoting a figure — one of them does not measure what its label says.
Copy-paste survival through Slack, Notion, Discord and Google Docs is untested. We do not repeat vendor claims about it.
Security review
Everything an AppSec questionnaire asks, without contacting us.
- Licence. Apache-2.0, including its express patent grant. See LICENSE.
- Dependencies. Exactly one at runtime:
$ python -c "from importlib.metadata import requires; \ print([r for r in requires('c2patxt') if 'extra ==' not in r])" ['cryptography~=48.0']
The optional[trust]extra addspyhanko-certvalidatorfor callers building their ownTrustEvaluator; nothing in this package imports it. - Offline. No network, no environment scanning, no credential store, no config
discovery, no log records. The suite runs with
--disable-socket, so egress fails the build. - Supported Python. 3.10 – 3.14, CPython.
- Vulnerability reporting. SECURITY.md — a CRA Article 24(1) steward policy: 48h acknowledgement, 90-day coordinated disclosure.
- SBOM and provenance. Every release carries CycloneDX and SPDX SBOMs, both attested, plus SLSA build provenance. All three are attached to the GitHub release.
Verify a release yourself:
$ gh attestation verify ./c2patxt-X.Y.Z-py3-none-any.whl -R dualeai/c2patxt
$ pypi-attestations verify pypi --repository https://github.com/dualeai/c2patxt <url>
$ uv export --frozen --no-emit-project -o requirements.txt
$ pip install --require-hashes -r requirements.txt
The first proves artifact → commit (SLSA). The second proves artifact → publisher (PEP 740) and needs no GitHub account. The third pins the tree by hash.
In PyPI's own words: "An attestation will tell you where a PyPI package came from,
but not whether you should trust it." Neither pip nor uv gates installation on
attestations. Verification is a step you run, not an assumption you inherit.
Specification status — read this before depending on it
- A.8 is under review. The specification says of itself that it "remains under
review and may be subject to change based on implementation feedback and
interoperability testing". That sentence was added on 2026-04-01 (commit
666bdf8f) and is the only change to A.8 across all eleven published 2.4 builds. - 2.4 has no release tag. The last tag is 2.3, where the clause is numbered A.7. The 2.4 PDF and HTML are published. That is why our claim cites a build hash.
- No certification exists to obtain. All 152 conformance-listed products are
certified against specification 2.2, and none declares a valid text media type
(one declares a bare non-IANA
txttoken). - The text conformance rubric is v0.1.0 — six manifest-level checks, no wire vectors. We pass all six.
What ships in this release, and what deliberately does not: release scope · releases
Four defects that cause silent divergence between conforming implementations are drafted for filing upstream: upstream filing.
Detail: compatibility · deviations · known divergences · open questions · robustness · mutation audit · benchmarks
We publish a wire-format conformance vector file
(tests/vectors/A8ConformanceTest-1.1.0.txt) because the rubric has none.
Interoperability with the two other public A.8 implementations is tested in
tests/test_third_party_interop.py; where we deliberately disagree with them, and
why, is in known divergences.
Reproduce
$ make install && make test # static checks + every test except the benchmarks
$ make lint # ruff, pyright strict, vulture
$ curl -sSL -o train.jsonl \
"https://zenodo.org/records/18620130/files/train.jsonl?download=1"
$ uv run python -m tools.robustness train.jsonl # the robustness numbers
Licence
Apache-2.0. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file c2patxt-0.1.1.tar.gz.
File metadata
- Download URL: c2patxt-0.1.1.tar.gz
- Upload date:
- Size: 487.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2dca3c231aba224f797aece52296bd672581ec391d4b93330f83991d6973fed3
|
|
| MD5 |
13214dc038e284eb66d114f6f1b40a2d
|
|
| BLAKE2b-256 |
b8105754bdce426dbc77d1189ba2647175b3d90b2a0ae6c8d3550d8d360cc271
|
Provenance
The following attestation bundles were made for c2patxt-0.1.1.tar.gz:
Publisher:
release.yml on dualeai/c2patxt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
c2patxt-0.1.1.tar.gz -
Subject digest:
2dca3c231aba224f797aece52296bd672581ec391d4b93330f83991d6973fed3 - Sigstore transparency entry: 2360767676
- Sigstore integration time:
-
Permalink:
dualeai/c2patxt@632dbf3029b1e9a83a6c06f4ebe82243e81ef4ef -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/dualeai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@632dbf3029b1e9a83a6c06f4ebe82243e81ef4ef -
Trigger Event:
release
-
Statement type:
File details
Details for the file c2patxt-0.1.1-py3-none-any.whl.
File metadata
- Download URL: c2patxt-0.1.1-py3-none-any.whl
- Upload date:
- Size: 133.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fdd76585be020a85a1474dd3a144b3ee441240923ccdd463427b2ffd4a22b835
|
|
| MD5 |
bf4173eec7cb613c398137d3b877eccb
|
|
| BLAKE2b-256 |
7c214c54f62b396f1e1ee4631b8088d57af676c984b074ee368713ac66009384
|
Provenance
The following attestation bundles were made for c2patxt-0.1.1-py3-none-any.whl:
Publisher:
release.yml on dualeai/c2patxt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
c2patxt-0.1.1-py3-none-any.whl -
Subject digest:
fdd76585be020a85a1474dd3a144b3ee441240923ccdd463427b2ffd4a22b835 - Sigstore transparency entry: 2360767789
- Sigstore integration time:
-
Permalink:
dualeai/c2patxt@632dbf3029b1e9a83a6c06f4ebe82243e81ef4ef -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/dualeai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@632dbf3029b1e9a83a6c06f4ebe82243e81ef4ef -
Trigger Event:
release
-
Statement type: