Releases: dualeai/c2patxt
Release list
v0.2.0
What changed
v0.2.0 adds C2PA 2.4 validation for Annex A.8 text and changes the signed
manifest bytes from embed().
Upgrade: Existing v0.1.2 marks still verify; do not rewrite them. New
validation rules can change verdicts either way. Review the breaking changes
if you useEmbedContext, catch
MarkCorruptError, import resource-limit constants, or treatVALIDas
proof of an AI disclosure.
pip install --upgrade c2patxt==0.2.0c2patxt supports Python 3.10 through 3.14. Its base install depends on
cryptography~=48.0.
Breaking changes
- Signed output:
embed()moves padding from signedc2pa.hash.datato the
unprotected COSE header. The A.8 wrapper version stays1; the signed manifest
bytes change. - Meaning of
VALID:verify()may now returnVALIDwithout
c2pa.ai-disclosureordigitalSourceType; it no longer checksmodelType.
embed()still writes all three. If your application requires
an AI disclosure, check the authenticated assertions.VALIDdoes not prove
an AI disclosure. - Public API:
MarkCorruptError(msg, document, pos, code)becomes
MarkCorruptError(msg, pos, document_length=None, code=...);
.document_lengthreplaces.doc. v0.2.0 removesMAX_JUMBF_DEPTHand adds
MAX_CBOR_DEPTH,MAX_NONSTARTERS, andTextNormalizationError.
at_least()andraise_for_state()raiseValueErrorforUNMARKEDand
INVALIDthresholds. - Inputs to
embed():media_typerequires an exact lowercasetext/
prefix and no parameters.embed()rejectstext/htmlandtext/markdown.
For customEmbedContextvalues,manifest_uuidmust be UUIDv4;
instance_idandgenerator_namemust use 1 to 1,000,000 UTF-8 bytes; and
whenmust use a whole-minute UTC offset. - Certificates and
strip():Signerchecks issuer links and, by default,
each certificate's role profile.embed()checks certificate validity at
EmbedContext.when.strip()raisesMarkCorruptErrorrather than guess how
much to remove when a corrupt wrapper has no known length.
Validation changes
- The verifier accepts valid non-deterministic CBOR but rejects duplicate keys.
It accepts ES256, ES384, ES512, PS256, PS384, PS512, and Ed25519 claim
signatures;embed()signs with Ed25519. It accepts integer and legacy string
x5chainlabels in either the protected or unprotected COSE header and checks
critical headers. - The verifier reads
c2maand legacyc2mdStandard Manifests. It considers
c2cmandc2umduring selection, then reports them as unsupported. It reads
c2pa.actionsv1;embed()writes v2. - For text with several wrappers, the verifier requires the chosen wrapper's
signed exclusion to name its span. It permits extra ordered exclusions and
records them inVerdict.informational. New checks cover action relationships,
self-redaction, soft bindings, icons, cloud and external data, time-stamp
shape, and multiple parents. - The verifier checks every carried certificate's role profile and validity
window, but not issuer links, chain order, or trust. The caller's
TrustEvaluatormust build the RFC 5280 path and set anchor policy.
Safety, performance, and limits
- NFC normalization rejects more than 30 consecutive non-starters after NFKD
decomposition.embed()raisesTextNormalizationError;verify()reports
a malformed data hash. - The padding search prepares one manifest and signature per exclusion candidate.
The CodSpeed comparison shows the 1 MB embed simulation change
from 1,394.2 ms to 30.4 ms. It also shows four
regressions: 12-byte embed simulation and memory, valid 1 MB verification
memory, and hostile-decoy simulation. It contains no wall-clock measurement.
c2patxt includes no credential, trust anchors, path builder, or network
retrieval. A correct self-signed mark returns VALID with
signingCredential.untrusted, which does not mean validation failed. c2patxt
supports no post-quantum signature algorithms.
c2patxt is our implementation of C2PA 2.4 Annex A.8; the C2PA consortium did
not publish it. The verifier does not check sigTst2, rVals, revocation, full
ingredient validation, cross-manifest history, or other text and media carriers.
It returns general.error for a non-empty c2pa.time-stamp or a linked
ingredient assertion. See implementation scope and
C2PA compatibility.
Checks and links
make test passes 1,313 tests with 95.95% line coverage; make lint also passes.
Implementation scope · C2PA compatibility ·
Known deviations · Robustness measurements
Full changelog: v0.1.2...v0.2.0
v0.1.2
What's new
Tests and documentation. No behaviour changed.
Strip the docstrings and parse src/ at both tags: one explanatory string under a constant in constants.py is the only difference. Same API, same wire format, same bytes — text marked by 0.1.1 verifies under 0.1.2 and the reverse. The suite goes 1,195 → 1,344 tests, coverage unchanged at 99.83%.
Two things drove it: tests that graded the code against itself, and shipped claims with nothing holding them. Several of those claims were false.
Not published yet.
buildand TestPyPI succeeded, and the attestations exist.publish-pypithen waited fifteen minutes for a runner, ran no step, and was cancelled, soupload-releasewas skipped. This page carries no assets, 0.1.2 is not on PyPI, andpip install c2patxtgives you 0.1.1. Nothing is wrong with the artifact; the publish jobs need re-running. Release run.
$ pip install c2patxtPython 3.10 – 3.14, CPython. One runtime dependency: cryptography~=48.0.
Read
VALIDcorrectly. The credential in the quickstart is self-signed and this package ships zero trust anchors, 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 mistake this package most expects.
Breaking changes
None in the package. One for anyone vendoring the conformance vectors:
- The file is
A8ConformanceTest-1.2.1.txt. Three records added, none changed. - The parse recipe is the other way round — discard from the first
#, then split on;. The old order returns eight or nine fields on 34% of the corpus, because ten records carry a semicolon inside a comment. SHA256SUMSnow coversbadandstreamingas well. Onlythird_party/carries attribution obligations.
Tests that had no oracle
Conformance vector file, 1.1.0 → 1.2.1
Two header rules were false: the blanket UTF-8 claim (payload_hex, and an extract record's expect_hex, are opaque manifest bytes) and the @PartN grammar (six of the nine markers do not match it).
Rule 9 is new. Rule 4 reports corruptedWrapper for a malformed magic-matching candidate and rule 1 skips a non-matching run. Neither covers both in one text — where raising on the first hands a denial of service to anyone who can prepend twenty characters.
Three records added: X0021 (rule 9, the discriminating one), X0022 (manifestLength 0), X0023 (the 0x0F/0x10 boundary). 1.2.1 over 1.2.0 is a comment change alone.
Third-party interop
test_third_party_interop imported nothing from c2patxt. It checked EncypherAI's and writerslogic's vectors against a transcription of the specification in the test file.
Their vectors now run against build_wrapper and the scanner both ways, including the 256-byte unicode_all_bytes payload no extract test had carried. A real embed() wrapper is re-derived from A.8.2.2, and a real embed() signature checked against a Sig_structure built from RFC 9052 rather than from _cose.
Certificate profile
check_claim_signing_profile was exercised only as a unit call, through one certificate shape. Eight buildable violations now sign a document and require signingCredential.invalid carrying its diagnosis.
Delete the call from _accept_credential: seven of the eight fail, and all 41 tests in test_trust.py stay green. The eighth, an EKU that will not parse, dies at the hostile-parse boundary first.
embed() output
The round trip checked .state, and test_the_wrapper_carries_the_magic_number asserted MAGIC against itself. Now asserted: the success codes an intact mark carries, the magic in the emitted bytes, the emitted modelType, the assertion label set, the COSE alg.
COSE vectors
Eleven of the twelve vendored cose-wg files publish a top-level intermediates.ToBeSign_hex. One was asserted against.
Ten now run as positive oracles, sign-pass-02 as the external_aad negative oracle 13.2.3 requires us to fail, and eddsa-01's — nested under signers — as the multi-signer shape we never emit. An accounting test refuses to let a vendored file go unread.
CBOR refusal corpora
cbor-wg publishes rfc8949/bad (47 ill-formed inputs) and rfc8949-appendixA/streaming (11 indefinite-length encodings). Neither was here. Both are parametrized and must raise CborDecodeError. make download-vectors-cbor fetches them.
Two bad items are accepted on purpose: a tag 0 carrying a map is invalid under §5.3.2 and still well-formed under Appendix C, and 15.10.3.1 rejects only what is not well-formed.
A corpus is not a substitute for the test that names the rule. Delete the reserved-additional-information check and one test fails, the one written for it. The six Invalid AI rows stay green: those inputs then declare a width past the end of the buffer and die a step later, for the wrong reason.
Also gone: test_cbor.py's assert expected != "" or description, which could not fail, and a column it read that was never compared to anything.
Documentation corrections
Each re-checked against the primary source, not against our own notes.
| We published | True |
|---|---|
| c2pa-rs emits the manifest-relative signature URI — our stated reason for emitting that form | It emits the store-relative form (sdk/src/jumbf/labels.rs:79 at 9b6b2e52, pinned by its own test) and accepts the other on read |
| CRA Article 24(1) requires a coordinated vulnerability disclosure policy | 24(1) requires a cybersecurity policy. Disclosure is Article 13, a manufacturer duty |
| The HypoFuzz licence conflicts for an open-source project inside a company | Its carve-out reaches projects "not commercially supported, or … governed by a non-profit organization", which excludes this one |
| The JUMBF toggle assignment is ISO's | Bits 0–3 are JPEG WG1's JLINK WD 3.0, Table A.2; Private is C2PA 11.1.4.1.2's xxx1xxxx. 11.1.4.1.2 gives neither ID nor Signature, which is why three implementations get those two wrong |
| 152 conformance-listed products | 156, every entry specVersion: ["2.2"], at c2pa-org/conformance-public commit 7d19b332. Pinned by commit, not date: the list syncs more than once a day |
SECURITY.md now also carries the staged CRA dates: Chapter IV from 11 June 2026, Article 14 reporting from 11 September 2026, Article 24 from 11 December 2027.
Deviation 21 records what actually holds our signature-URI choice: what we emit is a MAJOR version of this package and of the vector file, and both readers take both forms.
The specification build hash resolves too. gh api repos/c2pa-org/specifications/commits/c7e55d5a returns c7e55d5a3c1e758eeabad058e501fadbb8cfe777, committed 2026-04-23T17:38:51Z — three minutes before the published PDF's last-modified time.
Three retractions
- The byte-level parse of
image_5jumbf.jpgwas cited as proof that real assets carry atoggles=0x02box. The file is not vendored, we can no longer say which corpus it came from, and nothing in the suite touches it. Now an observation; the divergence stands on the five implementations. - "No implementation anywhere enforces the label rules" now reads "of the implementations surveyed in this document, none does". Same for "every other implementation uses 4 bytes" on the box ID.
- The third mutation pass reported eleven survivors. There were thirteen: one provably equivalent, twelve suite gaps. And one filed as equivalent was not — dropping
_hashed_uri_list'sisinstance(key, str)narrowing failstest_attacker_cbor_of_the_wrong_shape_narrows_rather_than_raising, which has existed since the initial commit, while a comment in the source repeated the wrong finding.
New checks
Commit df22b89 stopped grading documentation in pytest, and two documents still claimed a test held them. The replacements constrain structure, never wording.
| Test | Holds |
|---|---|
test_compatibility.py |
Each row's "Where" column names a file citing that clause, and the core table is in clause order. Ten rows named a file that does not cite the clause |
test_deviations_index.py |
The clause index in deviations.md, regenerated from the document's own sections, and the numbering dense and ordered — five files cite deviations by ordinal, two in src/ |
test_readme_examples.py |
Every README Python block imports what it uses, and the quickstart runs as written |
test_doc_references.py |
Every relative link and backticked path, in every shipped document |
Two gaps stay, documented: a row naming no file passes, and nothing checks the reverse direction.
Claims that now name a test
Signerhas an escape hatch. "Refuses a non-conformant certificate at construction" was true only by omission:allow_nonconformant=Truepredates this release and is what lets the verifier be tested against credentials it must reject.- Size. The published band was measured against a pinned certificate serial while its spread was attributed to a random one — three attempts, each looking like a correction of the last. Published now: the ratio, 3.90 UTF-8 bytes per manifest byte, plus one reproducible point, 7,001 B per mark on a 1,797 B store (
test_the_published_size_figures_are_still_true). Other certificates are the caller's to measure. - CPU and memory. About 0.10 ms per MB of unmarked text, linear to 4.32 MB — pinned as a mechanism, one
str.findand one encode per call (test_verify_walks_unmarked_text_exactly_once). Peak memory about 3.4× the manifest store, not the document: 1.77 MiB on a 0.53 MiB store (test_a_repeated_actions_link_allocates_a_bounded_multiple_of_its_input). - Thread safety. Exercised rather than inferred, by
test_one_signer_marks_correctly_from_many_threads. - **A robustness row m...
v0.1.1
What's new
CI, tests, vendored vectors and documentation. No library code changed.
git diff v0.1.0 v0.1.1 -- src/ is empty: same API, same wire format, same bytes. The v0.1.0 notes describe what the package does.
v0.1.0 never published. Its Release run failed at the attestation step, so nothing reached PyPI and that page carries no assets. v0.1.1 is the first version you can install. Release run.
$ pip install c2patxtPython 3.10 – 3.14, CPython. One runtime dependency: cryptography~=48.0.
Read
VALIDcorrectly. The credential in the quickstart is self-signed and this package ships zero trust anchors, 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 mistake this package most expects.
Breaking changes
None in the package, and nothing to upgrade from. One for anyone vendoring the conformance vectors: tests/vectors/third_party/ had no PROVENANCE.md and no licence notice, while the vectors README stated CC0 with no scope qualifier over a directory holding MIT and Apache-2.0 files that do carry attribution obligations.
| Path | Licence | Attribution obligation |
|---|---|---|
A8ConformanceTest-*.txt, SHA256SUMS |
CC0 1.0 | none |
cbor/ (cbor-wg) |
BSD-2-Clause | none |
cose/ (cose-wg) |
Unlicense | none |
third_party/ (EncypherAI, writerslogic) |
MIT, Apache-2.0 | yes |
The test enforcing provenance iterated (COSE, CBOR) — the two permissive corpora — and skipped the encumbered one. It now covers all three, and requires a commit-shaped pin and both licence names. Each directory's PROVENANCE.md records its licence and the upstream commit it was taken at.
Why the first release failed
actions/attest picks its mode from its inputs. predicate-type plus predicate selects Custom mode, which takes the predicate verbatim, so predicate: "{}" uploaded a SLSA v1 statement with no buildDefinition and the API rejected it — "Failed to persist attestation: Invalid Argument - build definition is nil". The run logged Attestation type: Custom, which is the tell. actions/attest-build-provenance builds the predicate from the workflow run instead, so it can name the builder, the invocation and the commit.
A release event reads the workflow from the tagged commit, not from main. A fix landed on main afterwards does nothing, and re-running re-reads the same broken file. Move the tag, or cut the next version. Now in CONTRIBUTING.
The release notes are the changelog
CHANGELOG.md is deleted. A changelog file and a release page are one document maintained twice, and the file is the copy that goes stale — it carried ## [0.1.0] — unreleased while the release was being published. pyproject.toml already pointed Changelog at /releases. Its two sections with no other home, the versioning caveat and "Evaluated but rejected", are in the v0.1.0 notes. The sdist no longer ships it, and docs/release-scope.md no longer links it.
So this page is load-bearing. test_the_published_size_figures_are_still_true holds the numbers printed here, in the README and in the platform handoff: store exactly 1,797 B, inflation 3.89–3.91 against the README's 3.90, 6,900–7,100 B per mark against the handoff's 7,001. The last two are measured under the pinned context, not a real UUID and clock. They went stale silently once, when the manifest gained the c2pa.metadata assertion the conformance rubric requires and three documents kept quoting pre-metadata figures. A deliberate size change now fails the test, forcing the documents to move in the same commit.
Claims the first release falsified
Found by grepping for statements a published, public repository makes untrue:
- README's "not published yet" blockquote, and the
PLANNEDqualifier on SBOMs and provenance. - SECURITY.md's "not published and this repository is internal" block, and its "No release exists yet" supply-chain paragraph.
codspeed.yml's reasoning that OIDC is needed because CodSpeed's tokenless path is public-repositories-only.
SECURITY.md also claimed private vulnerability reporting was enabled. It was not. It is now, so the advisory link in the shipped policy resolves. Two live channels: GitHub Security Advisories, or security@duale.ai.
A test that had no oracle: CBOR major type 7
cbor-wg publishes mt7-float and mt7-simple in the tests/rfc8949-appendixA/ directory we fetch mt0–mt6 from. Both landed in e7ba3eb5 on 2026-01-21, the same commit that added mt0–mt6. We fetched six of seven major types out of a directory holding all seven; three places in the suite then asserted type 7 had no corpus and worked around it.
The corpus goes 42 → 70 items across nine files, all eighteen .cbor and .edn bytes identical to upstream at 001eb684. Nine are refused, every refusal correct: six non-shortest-form floats and non-canonical NaNs that RFC 8949 4.2.1 and 4.2.2 forbid — upstream's own descriptions read "Infinity coded as f32 instead of f16" — and three simple values this package does not carry. Determinism enforcement is now asserted against somebody else's published bytes rather than vectors written here, and the accepted/refused test has both arms populated for the first time, 61 against 9.
The new items exposed a latent bug in that test. writer_refuses() returns True both when dumps refuses a value and when loads never produced one, and the classifier then called loads a second time unguarded. Unreachable while the corpus had no mt7.
make download-vectors-cbor fetches both files, and SHA256SUMS covers them.
Documentation corrections
Each was contradicted by a file already in the tree.
| We published | True |
|---|---|
eddsa-* use Ed25519 with alg = -8 |
eddsa-sig-02 is Ed448, vendored because 13.2.1 forbids it |
Each file carries intermediates.ToBeSign_hex |
eddsa-01 is a COSE_Sign, the multi-signer structure, not a COSE_Sign1: it carries intermediates.signers, which puts its Sig_structure one level down. Eleven of the twelve publish the top-level field; this is the one that does not |
sign1-* add three passing and six failing cases |
All nine from upstream's sign1-tests/ — sign-pass-01…03, sign-fail-01…04, 06, 07 — are ES256 over P-256, outside our narrowing. Useless as signature oracles, valuable as Sig_structure oracles, which is algorithm-independent |
Upstream last pushed 2024-03-13 (cose/), 2026-02-22 (cbor/) |
A push date cannot tell a maintainer whether the vectors moved. cbor/PROVENANCE.md and cose/PROVENANCE.md now record the upstream commit and the date the vendored directory last changed |
sign-fail-05 does not exist upstream. The gap in the numbering is theirs, not a vendoring error.
The push-date rule is the same mutable-reference rule CLAUDE.md applies to GitHub Actions.
Evaluated and rejected
- Re-running the failed v0.1.0 run, or moving its tag. Re-running re-reads the same broken workflow; moving a published tag makes the release page describe bytes it did not build. Cutting v0.1.1 leaves the record intact.
- Filtering the vendored third-party vectors. Two of EncypherAI's fifteen records are A.8 unstructured text —
ascii_smallandunicode_all_bytes; the other thirteen cover formats this package does not implement. Vendored whole so the file stays diffable against upstream: a filtered copy cannot be checked for drift. - Counting refused corpus items in aggregate. As one number the assertion was carried entirely by tags, and mutating
dumpsto emit float64 — a MAJOR wire change — left it green. Counted by reason, each half says what it checks.
Verify this release yourself
$ 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>The first proves artifact → commit; the predicate names refs/tags/v0.1.1 and commit 632dbf3029b1e9a83a6c06f4ebe82243e81ef4ef. The second proves artifact → publisher (PEP 740) and needs no GitHub account.
Three attestations sign the same wheel and sdist digests — https://slsa.dev/provenance/v1, https://cyclonedx.org/bom, https://spdx.dev/Document/v2.3. Both SBOMs are attached here, and either can be checked alone with --predicate-type https://cyclonedx.org/bom.
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.
Versioning
The public API stays unstable until 1.0.0; anything outside c2patxt.__all__ is private. Any change to the bytes we emit is a MAJOR version of both this package and the conformance vector file, because text marked by an older version must keep verifying.
What ships and what deliberately does not: release scope · where we disagree with other implementations, and why: known divergences
Full Changelog: v0.1.0...v0.1.1
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 ...