GitHub - dualeai/c2patxt: Duale AI's implementation of C2PA text marking (C2PA 2.4 Annex A.8)

GitHub

14 min read Original article ↗

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 NFC-normalized text. The selectors are designed not to render; actual rendering depends on the consuming text system.

This is Duale AI's implementation of C2PA text marking. It is not a C2PA consortium release and carries no conformance certification.

Python 3.10+. The base install has one direct runtime dependency: cryptography.

Or from a checkout:

$ git clone https://github.com/dualeai/c2patxt && cd c2patxt
$ make install

Five minutes — working code, and the certificate rules people get wrong · Why this exists — the AI Act obligation · What this proves · Rendering the verdict — read this before you print anything to a user · Limits · Security review · Specification status


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)
        # Optional on a leaf; when present, cA must be false.
        .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 and Signer refuses to construct (14.5.1.1), unless you
        # also pass allow_nonconformant=True.
        .add_extension(x509.ExtendedKeyUsage([C2PA_CLAIM_SIGNING_EKU]), critical=False)
        .sign(key, None)  # Ed25519 takes no separately selected certificate hash
    )


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)
# NFC-normalized text followed by selectors designed not to render

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) parses and returns the manifest without cryptographic validation. locate(text) returns the wrapper's byte span without decoding the manifest.

Those five are the call surface, and producing a mark also needs Signer, Disclosure and ModelType. Context, result, error and resource-limit types are exported beside them. Read c2patxt.__all__ for the exact 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 this implementation rejects it as signingCredential.invalid. C2PA separately permits a validator's private credential store to accept an exact credential. Signer refuses a non-conformant certificate at construction, so you find out now rather than after the bytes ship — unless you pass allow_nonconformant=True, which exists so the verifier can be tested against credentials it must reject.

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. Basic Constraints may be absent on a leaf; when present, it must not assert cA. Key Usage and a non-empty EKU are required, and Signer refuses violations unless you pass allow_nonconformant=True.

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.


Why this exists

EU AI Act Article 50(2) obliges providers of AI systems generating synthetic text to mark it 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 added 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.

The Commission adopted the Guidelines on 20 July 2026. They give practical guidance under Article 96; they do not turn this package or C2PA into a legal safe harbour.

What this does not do. It does not make anyone compliant, and the law mandates no standard: 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.


What this proves, and what it does not

Proves. That the holder of the signing key bound the signed declaration to the NFC-normalized covered text and that the signature and binding still validate. This is a cryptographic statement, not a claim that the stored bytes are unchanged: canonically equivalent text can have different UTF-8 bytes and the same binding input.

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.

The third 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.

verdict.manifest is None when no manifest can be parsed and selected: unmarked text, a corrupt wrapper, or plural wrappers whose signed exclusions do not identify exactly one of them. Once selected, it remains available when later assertion, binding, signature or credential validation fails. verdict.span is None only when no structurally valid wrapper was located; a plural-selection failure reports the first located span but no manifest.

State Say Never say
TRUSTED "Signature and certificate path accepted by your trust policy" "Signed by «name»" unless your application obtained that identity separately
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:

import pathlib

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. With the default context and this self-signed example, the mark carries one failure entry because no trust evaluator corroborates it:

state   : valid
success : assertion.hashedURI.match (4 entries), 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 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.

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.

Validation failures are verdicts; producer input errors are exceptions

Absent, corrupt and invalid marks are all Verdicts from verify. A string containing an unpaired surrogate raises UnencodableTextError at every text entry point because it cannot be represented on the A.8 UTF-8 wire. embed raises TextNormalizationError when normalization would exceed the limit below; verification reports the same condition as assertion.dataHash.malformed. The other three entry points also raise on malformed wrappers. For example:

verify   -> Verdict(state=invalid)
extract  -> raises MarkCorruptError
strip    -> raises MarkCorruptError
locate   -> raises MarkCorruptError

Catch C2paTextError; every named package error exported through c2patxt.__all__ derives from it. ProfileError is what Signer(...) raises for a non-conformant certificate. An uncaught MarkCorruptError on a public verification endpoint is a disclosure surface — see SECURITY.md.

Limits

Property Answer
Survives copy-paste of the full text Yes, where the application preserves variation selectors
Detects every stored-byte edit No. The binding covers NFC-normalized text; canonically equivalent equal-width rewrites can still validate
Survives NFC/NFD/NFKC/NFKD The mark does. The binding fails when normalization changes the declared wrapper offset or the NFC-normalized covered text — see below
Detects a surviving mark whose NFC-normalized covered text changed Yes; removing the whole mark yields UNMARKED, not evidence about origin
Identifies the signer Only against anchors you supply
Package-owned network access None. No revocation fetch, OCSP, or timestamp authority
Determinism With VerifyContext(now=...) and an offline deterministic evaluator. By default verify reads the clock — see below
Signature algorithm Generation: Ed25519. Validation: ES256/384/512, PS256/384/512 and Ed25519, the full C2PA 13.2.1 set
Manifest hash algorithm Generation: SHA-256 by default; SHA-384 and SHA-512 are also permitted for the hard binding and hashed assertion references. This setting does not select the claim-signature algorithm
Quantum resistance No. Generated marks use Ed25519, and every signature algorithm accepted by the C2PA 2.4 profile is classical. See Cryptographic profile and quantum scope
Size cost Each manifest byte becomes one variation selector costing 3 or 4 UTF-8 bytes, plus the A.8 header and marker. Certificate fields and chain depth determine the manifest size, so measure your own credential
Thread safety One Signer is exercised concurrently by tests/test_embed.py::test_one_signer_marks_correctly_from_many_threads; verification caches are per-call
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 when no validated trusted timestamp is available. This implementation does not validate sigTst2, so it uses the validation clock. The same bytes therefore give different answers as a carried certificate 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=...) with a deterministic evaluator to make verify a pure function of its arguments. Without both, do not cache a verdict and treat it as permanent.

Resource limits

Verification allocates in proportion to its input and applies four component bounds. They are implementation policy, not limits from the specification:

Bound Value What it stops
MAX_MANIFEST_LENGTH 2 MiB Accepting or emitting a larger manifest payload
MAX_SELECTOR_RUN 2 MiB + 13 Decoding an unbounded candidate run of variation selectors
MAX_CBOR_DEPTH 32 Deeply nested attacker-controlled CBOR
MAX_NONSTARTERS 30 Canonically ordering an unbounded Unicode nonstarter sequence during NFC normalization

The last value is Unicode UAX #15's Stream-Safe Text Format boundary. This package rejects longer sequences rather than inserting U+034F, because UAX #15 says that insertion can make the result no longer canonically equivalent to the input.

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. Cap the body size at your edge. CodSpeed owns the CPU and memory measurements; the functional suite checks results and security bounds, not machine-dependent performance figures.

Per-attack survival rates are in docs/robustness.md (PAN'26 Text Watermarking task dataset, 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: each row says what transform it actually applies, which is not always what its name suggests.

Copy-paste survival through Slack, Notion, Discord and Google Docs is untested. We do not repeat vendor claims about it.


Security review

Security properties and release-verification commands are in SECURITY.md: the security properties and what holds each, the base runtime dependency with the command to check it, the supply-chain attestations with the commands to verify a release yourself, and the CRA Article 24(1) reporting policy. In short: Apache-2.0, one base direct runtime dependency, CPython 3.10–3.14, no package-owned network, no ambient configuration, no log records.


Specification status

  • A.8 is under review. The specification says it "remains under review and may be subject to change based on implementation feedback and interoperability testing".
  • The implementation claim names an exact build. The 2.4 HTML and PDF are published; compatibility records the source commit reviewed by this package.
  • This is not a C2PA consortium release and carries no conformance certification.

The repository includes a local wire fixture derived from the C2PA A.8 rules.

The rest of the documentation

Document Answers
compatibility Which clauses we implement, and what the claim excludes
deviations How we read the specification where it was unclear
open questions What we could not settle
upstream filing Four Annex A.8 ambiguities prepared for upstream review
robustness What a marked document survives, measured
implementation scope What the package includes and excludes
test design · benchmarks Manual mutation method, test-layer rules, and what CodSpeed holds
releasing How a release is cut (maintainers)

Reproduce

$ make install && make test        # static checks + every test except 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.