Skip to main content

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 adds pyhanko-certvalidator for callers building their own TrustEvaluator; 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 txt token).
  • 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

c2patxt-0.1.1.tar.gz (487.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

c2patxt-0.1.1-py3-none-any.whl (133.6 kB view details)

Uploaded Python 3

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

Hashes for c2patxt-0.1.1.tar.gz
Algorithm Hash digest
SHA256 2dca3c231aba224f797aece52296bd672581ec391d4b93330f83991d6973fed3
MD5 13214dc038e284eb66d114f6f1b40a2d
BLAKE2b-256 b8105754bdce426dbc77d1189ba2647175b3d90b2a0ae6c8d3550d8d360cc271

See more details on using hashes here.

Provenance

The following attestation bundles were made for c2patxt-0.1.1.tar.gz:

Publisher: release.yml on dualeai/c2patxt

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for c2patxt-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 fdd76585be020a85a1474dd3a144b3ee441240923ccdd463427b2ffd4a22b835
MD5 bf4173eec7cb613c398137d3b877eccb
BLAKE2b-256 7c214c54f62b396f1e1ee4631b8088d57af676c984b074ee368713ac66009384

See more details on using hashes here.

Provenance

The following attestation bundles were made for c2patxt-0.1.1-py3-none-any.whl:

Publisher: release.yml on dualeai/c2patxt

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.0

2 files

0.1.2

2 files

This release

0.1.1 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page