v0.1.0
First release. C2PA provenance for plain text, as a zero-width mark the reader never sees.
embed, verify, extract, strip and locate work end to end against C2PA Technical Specification 2.4 (2026-04-01), HTML build c7e55d5a, Annex A.8 — a manifest store encoded as Unicode variation selectors, appended after the visible text. Marked text renders as the original.
Duale AI's implementation of C2PA text marking. Not a C2PA consortium release, and it carries no conformance certification.
This version never published — install v0.1.1. v0.1.0's Release run failed at the attestation step, so nothing reached PyPI and no artifacts are attached here.
src/has not changed since, apart from one docstring: same API, same wire format, same bytes, same vectors through v0.1.2. This page describes the package as it stands today;pip installgives you 0.1.1.
$ pip install c2patxtPython 3.10–3.14, CPython. One runtime dependency: cryptography~=48.0.
Quickstart
from c2patxt import Disclosure, ModelType, Signer, embed, verify
# leaf must satisfy the 14.5.1.1 profile -- Signer refuses it otherwise.
# The full builder, using cryptography, is in the README.
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
verify(marked).state # Provenance.VALID
VALIDis not a failure. That credential is self-signed and no anchors ship, so a correct mark verifies asVALIDcarryingsigningCredential.untrusted. C2PA 14.3.5 defines Valid without requiring trust; 14.3.6 adds trust separately. Treating that as an error is the likeliest mistake to make with this package.
Four states, none meaning "fake"
| State | Means |
|---|---|
TRUSTED |
Valid, and the chain reaches an anchor you supplied |
VALID |
Intact and signed; the signer is not corroborated |
INVALID |
A mark is present and failed |
UNMARKED |
No mark — and not a finding about the text |
The surface
- Five functions are the call surface.
embed(),verify(),extract(),strip(),locate(). Producing a mark also needsSigner,DisclosureandModelType.__all__exports thirty names in all; anything outside it is private. verifyis total, the other three raise. Absent, corrupt and invalid marks are allVerdicts.extract,stripandlocateraiseMarkCorruptErroron a malformed wrapper. CatchC2paTextError; the subclass list can grow.Verdictis not boolean-convertible.bool(verdict)raisesTypeError, because"CLEAN" if verdict else "FAKE"would render unmarked text as forged and a frozen dataclass is always truthy. Name a threshold:verdict.at_least(Provenance.VALID)orverdict.raise_for_state(...).strip(embed(x))is not alwaysx.embednormalizes to NFC, because the hard binding is defined over the NFC form;striponly removes the wrapper.
Three checks that look right and are wrong
No CLI, no UI. Your interface is the only place this result reaches a human.
verdict.manifestas a proxy for "marked" — it is present whenever the manifest parsed, so it survives most failures, and it isNonefor unmarked text, a corrupt wrapper, more than one wrapper, and any structural failure inside the manifest.StatusCode.CLAIM_SIGNATURE_VALIDATED in verdict.codes()— true on tampered text. The signature over the claim is intact; only its binding to the text broke.if verdict.failure:— the most tempting, and it flags the normal outcome as an error: every mark verified without anchors carriessigningCredential.untrustedinfailure.stateanswers "is this good"; the buckets say which rules ran and how each came out.
What validation enforces on read
- The hard binding over the NFC text with the wrapper excluded, whose exclusion range must both name a located wrapper and be a suffix of the text. Drop the membership test and an attacker picks which bytes the hash covers; drop the suffix rule and a canonically-equivalent respelling slides the wrapper into the middle of the text and still verifies.
- Exactly one hard binding, else
assertion.multipleHardBindings. Text carrying more than one wrapper is rejected outright. - Every assertion authenticated by the claim's hashed URI; an assertion the claim does not link is
assertion.undeclared. The claim's required fields (15.6.2), and itssignatureURI resolving to this manifest. c2pa.ai-disclosureon every__Ninstance, carrying a non-emptymodelType. An actions assertion whose inception action is first, in the first actions assertion the claim links, and — forc2pa.created— carryingdigitalSourceType, which a template may supply.- Generator and action icon references (15.10.3.3). The 14.5.1.1 certificate profile, minus the two rules that cannot apply to a text-only implementation. Every certificate in
x5chaininside its validity window, not just the leaf. c2pa-kp-claimSigningis NOT required — 14.5.1.1 names no claim-signing OID — andmodelTypeis not restricted to Table 12, whose CDDL socket admits any string.
Wire format
- Hand-rolled deterministic CBOR, JUMBF box codec, COSE_Sign1 with
x5chainat RFC 9360 label 33.cbor2'scanonical=Trueimplements RFC 8949 §4.2.3 length-first ordering; C2PA 10.1 mandates §4.2.1 bytewise. Those produce different bytes for the same map, and the signature covers those bytes. - Ed25519 signing only. 13.2.1's list is wider — ES256/384/512, PS256/384/512, and EdDSA with Ed25519 its only permitted instance — so this narrowing is ours, not the specification's.
- The certificate is the part people get wrong. The leaf must carry a present, non-empty EKU, must not assert
cAorkeyCertSign, and must assertdigitalSignature.Signerrefuses a non-conformant certificate at construction, so you find out before the bytes ship — unless you passallow_nonconformant=True, which exists so the verifier can be tested against credentials it must reject.C2PA_CLAIM_SIGNING_EKUis1.3.6.1.4.1.62558.2.1. - No network, ever. No environment scanning, no config discovery, no log records. The suite runs with
--disable-socket, so egress fails the build.
A default install cannot reach TRUSTED
Anchors arrive only through VerifyContext(anchors_pem=...) — nothing is read from the environment, a bundled store or disk — and they do nothing without a TrustEvaluator you write. Zero trust anchors ship, and the default evaluator trusts nothing.
The optional trust extra installs pyhanko-certvalidator~=0.31 for callers building one. It is an extra because pyca/cryptography's x509.verification hardcodes an SPKI allowlist that excludes Ed25519 (pyca/cryptography#13391). Nothing here imports it; TrustEvaluator is a Protocol you implement.
Conformance evidence
- Wire-format vectors (
tests/vectors/A8ConformanceTest-1.2.1.txt, 29 records — 6embed, 23extract), published because the C2PA text rubric has none.test_vector_file.pydeliberately does not import the package; it checks the file against a second transcription of A.8, which is what makes the file evidence rather than a mirror of our encoder. - Three-way agreement with the encypher and writerslogic vectors (
tests/test_third_party_interop.py), run against the shipped codec as well as against the specification. Where we disagree with them, and why: known divergences. - 1,344 tests, 99.83% coverage, mutation-verified by hand. We broke the code deliberately and confirmed the tests fail; where one did not, we rewrote it. The audit: mutation-audit.md.
- The text conformance rubric is v0.1.0 — six manifest-level checks, no wire vectors. We pass all six, self-assessed. No certification exists to obtain: all 156 conformance-listed products, at
c2pa-org/conformance-publiccommit7d19b332, are certified against specification 2.2, and none declares a valid text media type.
Specification status
A.8 is under review. It says of itself that it "remains under review and may be subject to change based on implementation feedback and interoperability testing" — a sentence added by commit 666bdf8f on 2026-04-01, and the only change to A.8 across the 2.4 HTML builds. 2.4 has no release tag; the last is 2.3, where the clause is numbered A.7. Hence the build hash in our claim.
Four ambiguities that let two conforming implementations produce different bytes are written up for filing upstream: Four ambiguities in Annex A.8.
Bounds and budget
The bounds are ours, not the specification's:
| What | Limit |
|---|---|
MAX_MANIFEST_LENGTH |
2 MiB |
MAX_SELECTOR_RUN |
2 MiB + 13 |
MAX_JUMBF_DEPTH |
32 |
| Input text length | none, deliberately — cap the body size at your edge |
Every figure below names the fixture it was taken under, because none of them is true without one:
| Figure | Value | Fixture |
|---|---|---|
| CPU, unmarked text | ~0.10 ms per MB | linear to 4.32 MB |
| Peak memory, marked text | ~3.4× the manifest store, not the document | 1.77 MiB on a 0.53 MiB store |
| Size ratio | 3.90 UTF-8 bytes per manifest byte | measured; the stable figure |
| One mark | 7,001 B on a 1,797 B store | one self-signed Ed25519 leaf, pinned context and pinned key; 6,993–7,009 B varying the key alone |
The manifest is dominated by your certificate chain, so measure your own rather than budgeting from ours.
Limits
- No time-stamping (
sigTst2), no stapled OCSP (rVals), no revocation checking. C2PA 15.8 judges certificate validity at validation time, so the same bytes give different answers as the leaf expires. PinVerifyContext(now=...)andverifybecomes a pure function of its arguments; without it, do not cache a verdict and treat it as permanent. - No update manifests, no ingredient chains, no compressed manifests, no redaction. A.9 (structured text) and A.7 (HTML) are out of scope, A.9 permanently: its forms insert visible delimiters, which is what A.8 exists to avoid.
- Decomposing a marked document breaks it, with nothing visibly edited. The mark survives NFD — variation selectors have no decomposition — but the binding covers the bytes, and NFD rewrites them. Impact: any transport that normalizes (macOS filenames, some CMSes, some Java stacks) invalidates a document nobody edited.
- Robustness: per-attack survival rates in 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. Copy-paste through Slack, Notion, Discord and Google Docs is untested; we do not repeat vendor claims about it.
- Thread safety: safe to share. Every public type is a frozen dataclass, the digest cache is per-call, no module-level mutable state exists, and one
Signeracross threads is exercised bytest_one_signer_marks_correctly_from_many_threads.
Evaluated but rejected
Recorded so "why didn't you just use X?" is answered once.
c2pa-python/c2pa-rsfor the manifest. Its settings signer reserves10000 + sign_cert.len()bytes for the signature (sdk/src/settings/signer.rs:130at9b6b2e52) — roughly 11 KB with a typical PEM leaf. At 3.90 UTF-8 bytes per manifest byte, that reservation alone swamps the mark: sound for an image, unusable for a sentence. We did not run it, so no figure here is measured against its output.cbor2for the CBOR layer. It cannot express the encoding C2PA 10.1 mandates, and the signature covers those bytes. It ships as a dev dependency instead, as a differential-testing oracle against our codec.pycosefor COSE_Sign1. Five dependencies at 1.1.0 —cryptography,cbor2,ecdsa,attrs,certvalidator— for one detached-payloadSig_structure, which is a four-element CBOR array. Every runtime dependency is code that runs inside the caller's process on attacker-supplied input.- Reproducible builds as the provenance mechanism. Attestation answers the question instead — SLSA for artifact→commit, PEP 740 for artifact→publisher. Bit-for-bit reproducibility would not tell a third party who published a wheel.
- A CLI. How a CLI is exposed, and how it fits an existing pipeline, is the integrator's decision under constraints we cannot see. This is an SDK.
Versioning
The public API is unstable until 1.0.0.
The wire format is a separate axis. Any change to the bytes we emit is a MAJOR version of both this package and the vector file, because text marked by an older version must keep verifying.
Verify a release yourself
These name v0.1.1, the published one:
$ gh attestation verify ./c2patxt-0.1.1-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.txtArtifact → commit (SLSA), artifact → publisher (PEP 740, no GitHub account needed), then the tree pinned by hash. CycloneDX and SPDX SBOMs are attached to v0.1.1 and attested; PyPI uploads use Trusted Publishing, so no long-lived API token exists.
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.
Vulnerabilities: SECURITY.md, the CRA Article 24(1) steward policy — 48h acknowledgement, 90-day coordinated disclosure, private reporting enabled.
Why this exists
EU AI Act Article 50(2) 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.
This package implements marking for text. It makes nobody 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. Whether your deployment satisfies the Article is a question for your counsel, not for a library.
Proves: the text was marked by the holder of a specific signing key, it declares itself machine-generated, and not one byte of the covered text has changed since. Does not prove: that the content is accurate, or who the signer is in the world.
Absence of a mark proves nothing. Human text is unmarked. Text from a model that does not mark is unmarked. Stripped text is unmarked. The DKIM lesson: unsigned mail is not forged mail.
Apache-2.0, including its express patent grant. Detail: release scope · compatibility · deviations · known divergences · open questions · robustness · mutation audit · benchmarks
Full Changelog: https://github.com/dualeai/c2patxt/commits/v0.1.0