C2PA8 min

We Built a Full C2PA Library in TypeScript. Here's What the Spec Doesn't Tell You

We Built a Full C2PA Library in TypeScript. Here's What the Spec Doesn't Tell You

Most teams integrate C2PA by wrapping the Rust or C++ toolkits. We went the other way and implemented the entire stack in pure TypeScript: JUMBF box parsing per ISO 19566-5, COSE signing, manifest stores, claim validation, the works. It runs in browsers, in Lambda functions, and in React Native, with no WASM toolchain and no native builds.

That decision forced us through parts of the specification that library consumers never see. This post is a tour of the spec's sharp edges, and of the engineering judgement calls you only discover when your code has to survive real-world files.

Why TypeScript for content credentials

Content credentials are most valuable at the moment of capture. Capture happens in browsers, on phones, in serverless functions. If your provenance engine needs a native runtime, every one of those environments becomes an integration project instead of an npm install.

The immediate question is: why not use the official JavaScript tooling? The Content Authenticity Initiative maintains c2pa-js (via @contentauth/c2pa-web), but it only solves half the problem. It is strictly a reader—it inspects and verifies manifests in the browser, but cannot construct, hash, or cryptographically sign them at capture time. Furthermore, c2pa-js is a WebAssembly (c2pa.wasm) wrapper around Rust. In embedded mobile runtimes—specifically Apple's JavaScriptCore on iOS—WebAssembly is either completely unavailable or strictly blocked by OS execution policies.

By building a pure TypeScript library with zero WASM and zero native binary dependencies, we can bundle directly for constrained targets. In our SDK, esbuild compiles the library for target: 'ios16' into an embedded bundle for sdk-ios (running inside JavaScriptCore) and an es2022 IIFE for sdk-android. The JavaScript engine orchestrates JUMBF box serialization, asset parsing, and claim hashing, while cryptographic signing delegates across a native bridge to the device's hardware roots of trust: Apple Secure Enclave and Android Keystore.

There is a second, quieter benefit. Our SDK, API, and frontends share one language, which means the type definitions for a manifest are the same types the API validates against. Type-safe contracts across the trust boundary remove an entire class of serialization bugs.

JUMBF is the part nobody documents well

Anatomy of a C2PA manifest: everything the signature covers lives in the assertion store and claim.

The C2PA spec builds on JUMBF, ISO 19566-5, a generic box format. A manifest is a SuperBox containing a description box, a claim box, assertion boxes (JSON or CBOR), and a signature box. So far, so tidy.

In practice, you spend your time on two things the spec glosses over. First, C2PA-specific boxes like the salt box and the embedded file boxes used for thumbnails. Second, transport: in JPEG, the JUMBF payload rides inside APP11 segments that have to be discovered, ordered, and reassembled by a small state machine. Our JPEG.ts does exactly that, walking segments until start-of-scan and reconstructing multi-segment manifests.

None of this is conceptually hard. It is just undocumented enough that every implementation rediscovers it independently.

Three spec ambiguities we had to engineer around

The training-and-data-mining assertion moved its fields. Early drafts disagreed on whether entries live at the top level of the payload or nested under an entries key. Our reader accepts both shapes and our writer emits the current one. The code links the upstream spec issue, because the next implementer deserves the breadcrumb.

digitalSourceType comes in two flavors. Some implementations prefix values with https, others with http. If you validate strictly, you break interop with real files. We normalize on read and stay exact on write: tolerant reader, precise writer.

Claim versioning is not a number, it is a fork. V1 and V2 claims differ in URN prefixes, action assertion shapes (softwareAgent versus softwareAgentIndex), and template support. Our ActionAssertion keeps parallel mapping paths for both, and the Claim class derives its label from its version so the two never drift apart.

I would have assumed these were settled corners of the spec. They were not. If you are building on C2PA, budget time for this layer.

Validation is a product surface, not a checkbox

A boolean valid/invalid answer is useless in production. Claims handlers need to know what failed; our own re-signing pipeline needs structured results to build V3 ingredient assertions. So validation produces a flat list of spec-aligned status codes, and we distinguish MalformedContentError (the file is structurally broken) from ValidationError (the file is fine, the trust is not).

One honest admission: there is a TODO in Manifest.read() for compressed boxes (brob). We support the uncompressed path and fail loudly on the rest. Shipping an honest gap beats silently mishandling it.

Extending the assertion framework: custom JSON assertions

The C2PA spec is deliberately extensible: custom assertion types let you embed domain data into signed manifests. Reach for custom types only when the payload is genuinely domain-specific. A sensor scene or a confirmed license plate qualifies. A title or author does not.

The core abstraction is CustomJsonAssertion<T>, an abstract generic that serializes typed content into a JSON JUMBF box, reads it back, and round-trips without loss:

abstract class CustomJsonAssertion<TContent> extends Assertion {  public uuid: Uint8Array = JSON_UUID;  public content?: TContent;  readContentFromJUMBF(box: IBox): void {    // Validates box type and UUID, then casts JSON content    if (!(box instanceof JSONBox) || !uuidMatch(this.uuid, JSON_UUID))      throw new ValidationError(        ValidationStatusCode.AssertionJSONInvalid,        this.sourceBox,        `${this.label} assertion has invalid type`,      );    this.content = box.content as TContent;  }  generateJUMBFBoxForContent(): IBox {    const box = new JSONBox();    box.content = this.content;    return box;  }}

With that base class, concrete assertion types are minimal. Our TrustLabelAssertion declares its label and content type and inherits everything else:

const ASSERTION_LABEL = 'com.trustnxt.trust-label';class TrustLabelAssertion  extends CustomJsonAssertion<LabelContent> {  public label = ASSERTION_LABEL;  static loadFromManifest(manifest: Manifest) {    return CustomJsonAssertion.load(      ASSERTION_LABEL,      TrustLabelAssertion,      manifest,    );  }}

The label uses reverse-domain notation (com.trustnxt.trust-label) to avoid collisions with standard assertions. The LabelContent type carries GPS, orientation, acceleration, gravity, rotation rate, network state, device info, camera settings, and feature flags like license plate results. Each sensor field includes an unavailableReason discriminator, because knowing why data is missing is as important as having it.

Schema versioning follows four rules: new optional fields can be added without breaking existing readers; fields are never removed, only deprecated; absence is explicit via unavailableReason; type changes get a new field name. In evidence workflows, a signed assertion is immutable. Whatever enters it is attached to the asset for its entire lifetime. Design for the reader you will never meet.

AI training consent: the training-and-data-mining assertion

As the EU AI Act pushes data-governance duties onto AI pipelines, the C2PA ecosystem's answer is the training-and-data-mining assertion: a signed statement, inside the content itself, of whether it may be used for AI training. For each use case, like ai_training, ai_inference, or data_mining, the content carries a choice:

interface TrainingAndDataMiningEntry {  choice: 'allowed' | 'notAllowed' | 'constrained';  constraintInfo?: string;}// Example: allow data mining, block AI training{  entries: {    "c2pa.ai_training":  { use: "notAllowed" },    "c2pa.data_mining":  { use: "allowed" },    "c2pa.ai_inference": { use: "constrained",      constraint_info: "Licensed use only" }  }}

Because it lives inside the signed manifest, the preference travels with the asset and is tamper-evident. A robots.txt file can be ignored, stripped, or simply not checked. A signed assertion is bound to the bytes. Removing it invalidates the signature, and absence after expected presence is itself a signal.

The assertion supports two label namespaces. The CAWG (Creator Assertions Working Group) label, c2pa.training-mining, tracks the community specification. The original C2PA label exists for backward compatibility. The constructor defaults to CAWG:

constructor(isCAWG = true) {  super();  this.isCAWG = isCAWG;  this.label = isCAWG    ? AssertionLabels.cawgTrainingAndDataMining    : AssertionLabels.trainingAndDataMining;}

Our position on consent versus provenance: a training-consent assertion says what may happen to content. Our evidence pipeline proves what did happen to it, through sealed capture, verified integrity, and signed enrichment. When both halves work together, a data supply chain can answer two questions: was this content allowed to be used this way, and can we prove it was processed correctly. AI-Act-era compliance needs both answers.

What is C2PA, in one paragraph

C2PA (Coalition for Content Provenance and Authenticity) is an open standard for attaching cryptographically signed provenance to digital content. A manifest bundles claims, assertions (ingredients, actions, hashes), and a signature into the file itself. Any conformant tool can then verify who signed it, what was asserted, and whether anything changed since. If you’d like to know more, head over to our C2PA article!

Frequently asked questions

What about c2pa-js? Doesn't the Content Authenticity Initiative already have a JavaScript library? The official @contentauth/c2pa-web package is strictly for reading and inspecting manifests in the browser, not signing them. Furthermore, it relies on a WebAssembly (c2pa.wasm) binary compiled from Rust. WebAssembly cannot execute in embedded mobile runtimes like Apple's JavaScriptCore on iOS. Our library is pure TypeScript designed for end-to-end manifest creation, hashing, and signing across browser, serverless, and native mobile runtimes.

Why not just wrap c2pa-rs? Wrapping gives you someone else's abstraction boundary. We needed browser execution, streaming large assets, and custom assertion types, all of which sit below that boundary. The full reasoning is in our build-vs-buy post.

Does a TypeScript implementation validate like the reference tooling? Conformance is about bytes on the wire, not implementation language. Tolerant reading and exact writing is what keeps you interoperable.

Can I use the library without TrustNXT services? The core stack (assets, JUMBF, manifests, COSE, validation) is self-contained and open source as c2pa-ts on GitHub. Our certificate and timestamp services plug in through the Signer and TimestampProvider interfaces.

What about video? BMFF support exists with lazy box parsing and offset patching. It deserves its own post, and it got one in our streaming C2PA video deep-dive.

Will custom assertions break other validators? No. The spec requires conformant validators to handle unknown assertions gracefully. Generic verifiers will ignore or display them as unknown, and the signature still validates. Domain-aware tools, like our inspect API, render them fully.

Is the training assertion legally binding? It is a machine-readable, tamper-evident statement of the creator's terms. Its weight in a given jurisdiction is a legal question; its authenticity is a cryptographic fact. The EU AI Act's data-governance obligations make the machine-readable part operationally useful regardless of legal enforceability.

The code is the honest version of this story. Explore c2pa-ts on GitHub. If you are implementing C2PA yourself, start with the spec, expect the ambiguities above, and keep your readers tolerant and your writers exact.

Latest articles

How to find us

Want to learn more?