Skip to content

Releases: dualeai/c2patxt

v0.2.0

Choose a tag to compare

@clemlesne clemlesne released this 09 Aug 09:51

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 use EmbedContext, catch
MarkCorruptError, import resource-limit constants, or treat VALID as
proof of an AI disclosure.

pip install --upgrade c2patxt==0.2.0

c2patxt supports Python 3.10 through 3.14. Its base install depends on
cryptography~=48.0.

Breaking changes

  • Signed output: embed() moves padding from signed c2pa.hash.data to the
    unprotected COSE header. The A.8 wrapper version stays 1; the signed manifest
    bytes change.
  • Meaning of VALID: verify() may now return VALID without
    c2pa.ai-disclosure or digitalSourceType; it no longer checks modelType.
    embed() still writes all three. If your application requires
    an AI disclosure, check the authenticated assertions. VALID does not prove
    an AI disclosure.
  • Public API: MarkCorruptError(msg, document, pos, code) becomes
    MarkCorruptError(msg, pos, document_length=None, code=...);
    .document_length replaces .doc. v0.2.0 removes MAX_JUMBF_DEPTH and adds
    MAX_CBOR_DEPTH, MAX_NONSTARTERS, and TextNormalizationError.
    at_least() and raise_for_state() raise ValueError for UNMARKED and
    INVALID thresholds.
  • Inputs to embed(): media_type requires an exact lowercase text/
    prefix and no parameters. embed() rejects text/html and text/markdown.
    For custom EmbedContext values, manifest_uuid must be UUIDv4;
    instance_id and generator_name must use 1 to 1,000,000 UTF-8 bytes; and
    when must use a whole-minute UTC offset.
  • Certificates and strip(): Signer checks issuer links and, by default,
    each certificate's role profile. embed() checks certificate validity at
    EmbedContext.when. strip() raises MarkCorruptError rather 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
    x5chain labels in either the protected or unprotected COSE header and checks
    critical headers.
  • The verifier reads c2ma and legacy c2md Standard Manifests. It considers
    c2cm and c2um during selection, then reports them as unsupported. It reads
    c2pa.actions v1; 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 in Verdict.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
    TrustEvaluator must 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() raises TextNormalizationError; 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

Choose a tag to compare

@clemlesne clemlesne released this 06 Aug 17:31

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. build and TestPyPI succeeded, and the attestations exist. publish-pypi then waited fifteen minutes for a runner, ran no step, and was cancelled, so upload-release was skipped. This page carries no assets, 0.1.2 is not on PyPI, and pip install c2patxt gives you 0.1.1. Nothing is wrong with the artifact; the publish jobs need re-running. Release run.

$ pip install c2patxt

Python 3.10 – 3.14, CPython. One runtime dependency: cryptography~=48.0.

Read VALID correctly. The credential in the quickstart is self-signed and this package ships zero trust anchors, so a correct mark verifies as VALID carrying signingCredential.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.
  • SHA256SUMS now covers bad and streaming as well. Only third_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.jpg was cited as proof that real assets carry a toggles=0x02 box. 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's isinstance(key, str) narrowing fails test_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

  • Signer has an escape hatch. "Refuses a non-conformant certificate at construction" was true only by omission: allow_nonconformant=True predates 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.find and 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...
Read more

v0.1.1

Choose a tag to compare

@clemlesne clemlesne released this 06 Aug 13:57

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 c2patxt

Python 3.10 – 3.14, CPython. One runtime dependency: cryptography~=48.0.

Read VALID correctly. The credential in the quickstart is self-signed and this package ships zero trust anchors, so a correct mark verifies as VALID carrying signingCredential.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 PLANNED qualifier 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_small and unicode_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 dumps to 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

Choose a tag to compare

@clemlesne clemlesne released this 06 Aug 13:41

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 install gives you 0.1.1.

$ pip install c2patxt

Python 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

VALID is not a failure. That credential is self-signed and no anchors ship, so a correct mark verifies as VALID carrying signingCredential.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 needs Signer, Disclosure and ModelType. __all__ exports thirty names in all; anything outside it is private.
  • verify is total, the other three raise. Absent, corrupt and invalid marks are all Verdicts. extract, strip and locate raise MarkCorruptError on a malformed wrapper. Catch C2paTextError; the subclass list can grow.
  • Verdict is 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. Name a threshold: verdict.at_least(Provenance.VALID) or verdict.raise_for_state(...).
  • strip(embed(x)) is not always x. embed normalizes to NFC, because the hard binding is defined over the NFC form; strip only 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.manifest as a proxy for "marked" — it is present whenever the manifest parsed, so it survives most failures, and it is None for 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 carries signingCredential.untrusted in failure. state answers "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 its signature URI resolving to this manifest.
  • c2pa.ai-disclosure on every __N instance, carrying a non-empty modelType. An actions assertion whose inception action is first, in the first actions assertion the claim links, and — for c2pa.created — carrying digitalSourceType, 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 x5chain inside its validity window, not just the leaf.
  • c2pa-kp-claimSigning is NOT required — 14.5.1.1 names no claim-signing OID — and modelType is not restricted to Table 12, whose CDDL socket admits any string.

Wire format

  • Hand-rolled deterministic CBOR, JUMBF box codec, COSE_Sign1 with x5chain at RFC 9360 label 33. cbor2's canonical=True implements 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 cA or keyCertSign, and must assert digitalSignature. Signer refuses a non-conformant certificate at construction, so you find out before the bytes ship — unless you pass allow_nonconformant=True, which exists so the verifier can be tested against credentials it must reject. C2PA_CLAIM_SIGNING_EKU is 1.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 — 6 embed, 23 extract), published because the C2PA text rubric has none. test_vector_file.py deliberately 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-public commit 7d19b332, 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 ...
Read more