ext-c2pa
ext-c2pa is a PHP-native extension for C2PA Content Credentials: it reads and validates embedded provenance manifests, and signs image bytes — including every derivative of an original — entirely in memory, in-process, with no network access.
It is built in Rust on the official
c2pa crate
(the Content Authenticity Initiative’s Rust SDK), exposed to PHP 8.3+ via
ext-php-rs. The PHP surface
lives in the Automattic\VIP\C2PA namespace.
What it does
- Read —
Reader::fromBytes()validates an image’s embedded manifest and reports a verdict (Valid,Invalid,Trusted, or no manifest), plus the full manifest-store JSON and a compact UI-oriented summary. - Sign —
Builder::create()embeds a signed manifest into a new original;Builder::edit()re-signs a derivative as an edit whose provenance chains back to its parent via aparentOfingredient. - Trust —
Settings::withTrustAnchors()supplies a PEM anchor bundle so validation can distinguish cryptographically sound (Valid) from chained to a trusted signer (Trusted). - Identity —
Signer::fromPem()for bring-your-own-key signing, orSigner::generateSelfSigned()to mint a per-site development identity.
What it deliberately does not do
- No filesystem access. Every operation takes and returns raw bytes (ordinary PHP strings, which are binary-safe).
- No network access. The
c2pacrate is compiled without remote-manifest fetching or trust-list downloads, and signing never contacts a timestamp authority. What you configure is all there is.
That posture is what makes the extension safe inside a web request lifecycle: each call is stateless, bounded, and self-contained.
A 30-second taste
use Automattic\VIP\C2PA\Reader;
$reader = Reader::fromBytes(file_get_contents('photo.jpg'), 'image/jpeg');
if ($reader->hasManifest() && $reader->isValid()) {
$summary = json_decode($reader->summary(), true);
echo "Signed by: {$summary['signer']}\n";
}
Provenance
ext-c2pa is the native half of the wp-c2pa product — C2PA for the WordPress Media Library — whose plugin half lives at github.com/ericmann/wp-c2pa. The extension is WordPress-agnostic: nothing in this book requires WordPress.
Source: github.com/ericmann/ext-c2pa. License: GPL-2.0-or-later.
Installation
ext-c2pa targets PHP 8.3+ (NTS and ZTS) on Linux and macOS. Windows is not supported.
Pre-built binaries
Every tagged release attaches pre-built extension tarballs to the GitHub Release, named with PIE’s filename convention per platform and PHP minor:
php_c2pa-v0.1.0_php8.5-arm64-darwin-bsdlibc-nts.tgz
php_c2pa-v0.1.0_php8.4-x86_64-linux-glibc-nts.tgz
php_c2pa-v0.1.0_php8.3-arm64-linux-glibc-nts.tgz
The recommended path is PIE, which picks the
right tarball for your platform and PHP, verifies it, and wires up your
php.ini:
pie install ericmann/ext-c2pa
Or manually: each tarball contains the compiled extension as c2pa.so
(that exact name on macOS too — it’s PIE’s convention). Verify the
.sha256 sidecar, unpack it somewhere your PHP can read, and point your
php.ini at it:
extension=/path/to/c2pa.so
Verify:
$ php -m | grep c2pa
c2pa
Building from source
You need:
- Rust (the pinned toolchain in
rust-toolchain.tomlis installed automatically byrustup) - PHP 8.3+ with
php-configon yourPATH(thephp-dev/php-develpackage on most Linux distributions) - Linux:
build-essential libclang-dev— macOS: Xcode command-line tools
Then:
$ git clone https://github.com/ericmann/ext-c2pa
$ cd ext-c2pa
$ make build # debug build → target/debug/libc2pa.{so,dylib}
$ make test # PHPT suite against the just-built extension
$ make release # optimized build → target/release/libc2pa.{so,dylib}
Load the built library the same way as above, or one-off on the CLI:
$ php -d extension=target/debug/libc2pa.dylib -r 'var_dump(extension_loaded("c2pa"));'
bool(true)
make help lists the other targets (clippy, fmt, stubs,
install/uninstall via cargo-php, clean).
IDE stubs
stubs/c2pa.stubs.php mirrors the full PHP surface for IDEs and static
analyzers (PHPStan, Psalm, IntelliSense). Point your tooling at it — the
extension itself ships no PHP files.
Quickstart
A full round trip — sign an image, then validate what you signed — in one script. Everything operates on in-memory bytes: plain PHP strings in, plain PHP strings out.
<?php
use Automattic\VIP\C2PA\Builder;
use Automattic\VIP\C2PA\Reader;
use Automattic\VIP\C2PA\Signer;
const DIGITAL_CAPTURE = 'http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture';
// 1) A signing credential. selfSigned() is a development-only identity
// compiled into the extension — see "Signing Identities" for real ones.
$signer = Signer::selfSigned();
// 2) Sign an unsigned image as a new original.
$builder = Builder::create('photo.jpg', DIGITAL_CAPTURE);
$builder->addAssertion('c2pa.actions', json_encode([
'actions' => [['action' => 'c2pa.created']],
]));
$unsigned = file_get_contents('photo.jpg');
$signed = $builder->sign($unsigned, 'image/jpeg', $signer);
file_put_contents('photo-signed.jpg', $signed);
// 3) Read it back.
$reader = Reader::fromBytes($signed, 'image/jpeg');
var_dump($reader->hasManifest()); // true
var_dump($reader->validationState()); // "Valid"
var_dump($reader->isValid()); // true
// 4) The compact summary drives UI badges without JSON spelunking.
$summary = json_decode($reader->summary(), true);
// [
// 'state' => 'Valid',
// 'has_manifest' => true,
// 'signer' => '...', // certificate issuer
// 'claim_generator' => 'wp-c2pa',
// 'title' => 'photo.jpg',
// 'format' => 'image/jpeg',
// 'ai_generated' => false,
// 'actions' => ['c2pa.created'],
// 'ingredients' => [],
// ]
Images with no manifest are not an error — Reader::fromBytes() returns
normally with hasManifest() === false and an empty validationState(), so
“unsigned” and “invalid” stay distinct verdicts:
$reader = Reader::fromBytes($anyImage, 'image/jpeg');
match (true) {
!$reader->hasManifest() => 'no credentials',
$reader->isTrusted() => 'trusted', // chained to a configured anchor
$reader->isValid() => 'valid', // cryptographically sound
default => 'invalid', // tampered or broken
};
Genuinely malformed input — a corrupt asset or a mangled manifest — throws
Automattic\VIP\C2PA\C2paException (a \RuntimeException).
From here:
- Signing Originals — intents, assertions, generators
- Derivatives & Provenance Chains — the reason this extension exists
- Trust & Verdicts — turning
ValidintoTrusted
Reading & Validating
Reader::fromBytes() runs the complete C2PA validation once, up front, and
caches the verdict; every accessor after that is a cheap in-memory read.
use Automattic\VIP\C2PA\Reader;
use Automattic\VIP\C2PA\Settings;
$reader = Reader::fromBytes($bytes, 'image/jpeg'); // integrity only
$reader = Reader::fromBytes($bytes, 'image/jpeg', $settings); // + trust check
$bytes— the raw image, as an ordinary PHP string. PHP strings are binary-safe and cross the Rust boundary byte-for-byte; never run image bytes through any text/encoding transformation first.$mime— the asset’s MIME type (image/jpeg,image/png,image/webp, …). Thec2pacrate dispatches its parser on this value, so it must match the actual bytes.$settings— optional trust configuration. Without it, validation still verifies well-formedness and cryptographic integrity; it just can’t assert trust.
The three outcomes
| Input | Result |
|---|---|
| Image with no C2PA data | Normal return: hasManifest() is false, validationState() is "" |
| Image with a manifest | Normal return: verdict in validationState() |
| Corrupt asset / mangled manifest | Throws C2paException |
Treating “no manifest” as a clean verdict rather than an exception matters in bulk pipelines: a media library full of unsigned images is the normal case, not a failure mode.
Verdict accessors
$reader->validationState(); // "Valid" | "Invalid" | "Trusted" | ""
$reader->isValid(); // true for Valid or Trusted
$reader->isTrusted(); // true only for Trusted
Trusted is only reachable when Settings carried trust anchors — see
Trust & Verdicts for exactly what each state promises.
Two views of the manifest
json() returns the complete manifest store — every manifest in the
asset, all assertions, ingredients, signature info, and validation results —
as JSON. It is the full fidelity view, and the empty string when there was no
manifest.
summary() returns a compact, stable, UI-oriented projection of the
active manifest:
{
"state": "Valid",
"has_manifest": true,
"signer": "C2PA Test Signing Cert",
"claim_generator": "wp-c2pa",
"title": "photo.jpg",
"format": "image/jpeg",
"ai_generated": false,
"actions": ["c2pa.created", "c2pa.resized"],
"ingredients": [{ "title": "orig.jpg", "relationship": "parentOf" }]
}
Field notes:
signer— the issuer from the manifest’s signature certificate.claim_generator— the producing software’s name (handles both claim v1 and v2 layouts).ai_generated—truewhen anyc2pa.actionsassertion carries an IPTCdigitalSourceTypeoftrainedAlgorithmicMediaorcompositeWithTrainedAlgorithmicMedia; this is how “Made with AI” style badges are driven.actions— the flat list of action names (c2pa.created,c2pa.resized, …) across the manifest’s action assertions.ingredients— title + relationship per ingredient; aparentOfrelationship is the provenance link back to a parent asset.
When there is no manifest, summary() still returns the same shape with
has_manifest: false and null/empty fields — callers can decode it
unconditionally.
Signing Originals
Builder assembles a manifest declaratively — title, intent, assertions,
ingredients — and realizes it in a single sign() call that embeds the
signed manifest into the image bytes and returns the result. Nothing touches
disk; no timestamp authority or other network service is contacted.
Create intent
A brand-new original uses Builder::create():
use Automattic\VIP\C2PA\Builder;
use Automattic\VIP\C2PA\Signer;
$builder = Builder::create(
'photo.jpg',
'http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture'
);
$signed = $builder->sign($unsignedBytes, 'image/jpeg', $signer);
The second argument is an IPTC DigitalSourceType
URL declaring where the content came from — digitalCapture for a camera
photo, trainedAlgorithmicMedia for generative-AI output, and so on. An
unknown or empty value falls back to digitalCapture.
(For derivatives of an existing asset, use Builder::edit() instead — that’s
its own chapter.)
Assertions
Assertions are the manifest’s claims about what happened. addAssertion()
takes the assertion label and its data as a JSON string:
$builder->addAssertion('c2pa.actions', json_encode([
'actions' => [['action' => 'c2pa.created']],
]));
The extension validates that the JSON parses but otherwise passes assertions through verbatim — any label/shape the C2PA spec (or your pipeline) defines is fair game.
The claim generator
The manifest records what software produced it. By default that reads
wp-c2pa (with the extension’s version); override it to attribute the
credential to your organization or product:
$builder->withGenerator('NASA');
An empty or whitespace-only name resets to the default.
Signing
$signed = $builder->sign($bytes, 'image/jpeg', $signer);
sign() may be called with any Signer. The returned
string is the complete signed image — write it wherever the original came
from. The builder itself is not consumed; its accumulated state is applied
fresh on each call.
Failures throw C2paException with a c2pa signing failed: … message
(malformed assertion/ingredient JSON fails earlier, at the add* call, as
invalid input: …).
Derivatives & Provenance Chains
This is the headline feature. Real pipelines rarely serve the original file: a CMS generates thumbnails, crops, and responsive sizes, and those are what end up in pages. If only the original is signed, the provenance evaporates at render time — every derivative is just an unsigned image.
ext-c2pa closes that gap: each derivative is re-signed as an edit whose
manifest carries the original as a parentOf ingredient. The derivative then
holds its own valid credential and a verifiable chain back to what it was
made from.
The pattern
use Automattic\VIP\C2PA\Builder;
use Automattic\VIP\C2PA\Reader;
use Automattic\VIP\C2PA\Signer;
$signer = Signer::selfSigned(); // dev only — see "Signing Identities"
// 1) Sign the full-size original (Create intent).
$orig = Builder::create('photo.jpg', 'http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture');
$orig->addAssertion('c2pa.actions', json_encode(['actions' => [['action' => 'c2pa.created']]]));
$signedOriginal = $orig->sign($originalBytes, 'image/jpeg', $signer);
// 2) Re-sign each generated size as an EDIT chained to the SIGNED original.
$deriv = Builder::edit('photo-300x200.jpg');
$deriv->addIngredientFromBytes(
json_encode(['title' => 'photo.jpg', 'relationship' => 'parentOf']),
'image/jpeg',
$signedOriginal // the parent's *signed* bytes
);
$deriv->addAssertion('c2pa.actions', json_encode(['actions' => [['action' => 'c2pa.resized']]]));
$signedDerivative = $deriv->sign($resizedBytes, 'image/jpeg', $signer);
Three details carry the meaning:
Builder::edit()— the edit intent takes no DigitalSourceType; this manifest describes a transformation, not an origin.addIngredientFromBytes()withrelationship: parentOf— pass the parent’s signed bytes, so the ingredient captures the parent’s own manifest and the chain is cryptographically anchored, not just a title reference.- A
c2pa.resizedaction — says what the edit was. Use whichever C2PA action fits the transformation (c2pa.cropped,c2pa.color_adjustments, …).
What validation sees
$r = Reader::fromBytes($signedDerivative, 'image/jpeg');
$s = json_decode($r->summary(), true);
$r->isValid(); // true — the derivative's own manifest verifies
$s['actions']; // ['c2pa.resized']
$s['ingredients'][0]; // ['title' => 'photo.jpg', 'relationship' => 'parentOf']
And with the signing CA configured as a trust anchor, the whole chain
verifies to Trusted — original and every size (this is exactly what the
test suite asserts, and what c2patool independently confirms against the
same files).
Deeper chains
An edit of an edit works the same way: sign the crop with the resized
version’s signed bytes as its parentOf ingredient. Each manifest records
one hop; the chain is the concatenation of hops, walkable through
json()’s full ingredient data.
Trust & Verdicts
C2PA validation answers two different questions, and the API keeps them separate:
- Integrity — is the manifest well-formed and cryptographically sound? Has the image been altered since signing?
- Trust — was it signed by someone I recognize?
Integrity is intrinsic to the bytes. Trust requires you to say who you trust,
and that’s what Settings is for.
Configuring anchors
use Automattic\VIP\C2PA\Reader;
use Automattic\VIP\C2PA\Settings;
$settings = new Settings();
$settings->withTrustAnchors(file_get_contents('/etc/c2pa/anchors.pem'));
$settings->hasTrustAnchors(); // true
$reader = Reader::fromBytes($bytes, 'image/jpeg', $settings);
The anchor bundle is one or more concatenated PEM certificates — the official
C2PA trust list export, your organization’s CA, a dev CA, or any mix.
Passing an empty string clears the anchors. Settings can also be handed to
Builder::create()/Builder::edit() to apply the same configuration on the
signing path.
There is no implicit trust list: the extension never fetches one from the network, and with no anchors configured nothing is trusted. What you provide is the entire trust universe.
The verdict ladder
validationState() | Meaning |
|---|---|
"" | No manifest at all (hasManifest() is false) |
"Invalid" | Manifest present but broken: tampered content, bad signature, malformed claim |
"Valid" | Cryptographically sound; signer chain not verified against anchors |
"Trusted" | Sound and the signing chain verifies to a configured anchor |
isValid() is true for Valid or Trusted; isTrusted() only for
Trusted. Without anchors, Trusted is unreachable and Valid is the
ceiling.
Choosing what a badge means
For a UI, the useful mapping is usually:
Trusted→ full credential badge, named signerValid→ “has credentials” (sound, but from a signer you haven’t anchored)Invalid→ warning — a broken credential is a stronger signal than no credential- no manifest → nothing to show
Anchoring your own signing CA (see
Signing Identities) makes your own pipeline’s output
validate as Trusted end-to-end — originals and every derivative.
Signing Identities
A Signer is a signing credential: a certificate chain, its private key, and
an algorithm. It’s inert until Builder::sign() uses it — construction never
contacts anything, and signing never involves a timestamp authority.
There are three ways to get one, in increasing order of seriousness.
1. The compiled-in dev signer
$signer = Signer::selfSigned();
$signer->algorithm(); // "es256"
An ES256 credential baked into the extension, chaining to a throwaway dev CA. It exists so tests, demos, and local pipelines work with zero setup.
Never use it as a real signatory — the private key ships in every copy of the extension, so its signatures assert nothing.
2. A generated per-site identity
Signer::generateSelfSigned() mints a fresh ES256 identity — a root CA plus
a leaf signing certificate that meets the C2PA certificate profile — with
your organization’s name as the subject, so credentials read as you rather
than a tool name:
$identity = json_decode(Signer::generateSelfSigned('NASA'), true);
// [
// 'chain' => <leaf + CA PEM>, // use as the Signer cert chain
// 'key' => <PKCS#8 PEM>, // the leaf's private key
// 'ca' => <CA PEM>, // add to your trust anchors
// ]
$signer = Signer::fromPem($identity['chain'], $identity['key'], 'es256');
Two obligations come with it:
- Persist the result (somewhere secret, for the key). The CA is random per call — regenerate and you’ve orphaned everything already signed.
- Anchor the
caviaSettings::withTrustAnchors()so your own output validates asTrusted.
This is a self-signed trust root: perfect for a site or organization verifying its own pipeline, meaningless to third parties who haven’t chosen to anchor your CA.
3. Bring your own key
For a real signatory — a certificate issued by a CA that verifiers actually anchor — load your PEM material directly:
$signer = Signer::fromPem(
file_get_contents('/etc/c2pa/chain.pem'), // leaf-first PEM chain
file_get_contents('/etc/c2pa/key.pem'), // matching PKCS#8 private key
'es256'
);
Requirements:
- The chain is leaf-first (signing cert, then intermediates/root).
- The leaf must meet the C2PA signing-certificate profile (digitalSignature key usage, emailProtection EKU, not a CA certificate).
- Supported algorithms:
es256,es384,es512,ps256,ps384,ps512,ed25519(case-insensitive).
An unsupported algorithm or empty PEM throws C2paException
(invalid signer configuration: …) at construction; a chain/key mismatch
surfaces when signing.
The key never leaves the process — there is no signing service in the loop, which is the point: the credential holder is the platform operator, not a third party.
PHP API
Everything lives in the Automattic\VIP\C2PA namespace. This page is the
complete surface; stubs/c2pa.stubs.php in the repository mirrors it for
IDEs and static analysis.
All $bytes parameters and byte returns are ordinary PHP strings carrying
raw binary — no encoding, no base64.
Settings
Trust configuration handed to Reader and Builder. See
Trust & Verdicts.
final class Settings
{
public function __construct();
/** Set the PEM bundle of trusted C2PA anchors. Empty string clears it. */
public function withTrustAnchors(string $pem): void;
/** Whether trust anchors are configured. */
public function hasTrustAnchors(): bool;
}
Reader
Reads and validates an embedded manifest from image bytes. See Reading & Validating.
final class Reader
{
/**
* Validate raw image bytes of the given MIME type. A no-manifest image
* is not an error: hasManifest() is false, validationState() is "".
* @throws C2paException on corrupt assets / malformed manifests
*/
public static function fromBytes(string $bytes, string $mime, ?Settings $settings = null): Reader;
public function hasManifest(): bool;
/** "Valid" | "Invalid" | "Trusted" | "" (no manifest). */
public function validationState(): string;
/** True for "Valid" or "Trusted". */
public function isValid(): bool;
/** True only when the chain verified against configured trust anchors. */
public function isTrusted(): bool;
/** Full manifest-store JSON; "" when there was no manifest. */
public function json(): string;
/**
* Compact UI summary JSON: { state, has_manifest, signer,
* claim_generator, title, format, ai_generated, actions[],
* ingredients[] }.
*/
public function summary(): string;
}
Signer
A signing credential. See Signing Identities.
final class Signer
{
/** Development-only ES256 credential compiled into the extension. */
public static function selfSigned(): Signer;
/**
* BYOK credential. $certChainPem is leaf-first; $privateKeyPem is the
* matching PKCS#8 key; $alg is one of
* es256|es384|es512|ps256|ps384|ps512|ed25519.
* @throws C2paException on unsupported alg / empty PEM
*/
public static function fromPem(string $certChainPem, string $privateKeyPem, string $alg): Signer;
/**
* Generate a fresh ES256 signing identity for $org, as a JSON string
* { "chain": <leaf+CA PEM>, "key": <PKCS#8 PEM>, "ca": <CA PEM> }.
* Persist it and anchor "ca"; build a Signer from chain+key via
* fromPem(). An empty $org defaults to "WordPress".
*/
public static function generateSelfSigned(string $org): string;
/** The signing algorithm, lowercased (e.g. "es256"). */
public function algorithm(): string;
}
Builder
Assembles and embeds a signed manifest into image bytes. See Signing Originals and Derivatives.
final class Builder
{
/** New original. $sourceType is an IPTC DigitalSourceType URL. */
public static function create(string $title, string $sourceType, ?Settings $settings = null): Builder;
/** Edit of a parent asset. Attach the original via addIngredientFromBytes(). */
public static function edit(string $title, ?Settings $settings = null): Builder;
/** Override the claim-generator (producer) name; defaults to "wp-c2pa". */
public function withGenerator(string $name): void;
/**
* Add an assertion; $json is the assertion data as a JSON string.
* @throws C2paException on unparseable JSON
*/
public function addAssertion(string $label, string $json): void;
/**
* Attach an ingredient (e.g. the original as parentOf) from raw bytes.
* $ingredientJson is e.g. {"title": "...", "relationship": "parentOf"}.
* @throws C2paException on unparseable JSON
*/
public function addIngredientFromBytes(string $ingredientJson, string $mime, string $bytes): void;
/**
* Sign $bytes and return the signed image bytes.
* @throws C2paException when manifest assembly or signing fails
*/
public function sign(string $bytes, string $mime, Signer $signer): string;
}
C2paException
class C2paException extends \RuntimeException {}
The single exception type for every failure — see Errors for the message taxonomy.
Design & Guarantees
The promises the extension makes, and the engineering behind them.
In-memory, in-process, no I/O
Every operation takes byte buffers and returns byte buffers. The underlying
c2pa crate is compiled with default-features = false:
- no
file_io— the extension cannot read or write files; - no
fetch_remote_manifests— a manifest that points at a remote store is not fetched; there are no trust-list downloads; and signing passes no timestamp-authority URL. rust_native_crypto— cryptography is pure Rust (no OpenSSL linkage), so the built library is portable across systems with differing SSL stacks.
The result: validating or signing an image is a bounded, stateless function call. Nothing warms up, nothing persists, nothing talks to the outside world. That makes the extension predictable inside a web request — the original design constraint, coming from the WordPress upload pipeline.
Byte-safety across the PHP ↔ Rust boundary
Image bytes are not valid UTF-8, and treating them as text corrupts them. On
the Rust side every byte parameter is ext_php_rs::binary::Binary<u8> — a
byte-preserving view of the PHP string — never a Rust String. On the PHP
side you pass ordinary strings; PHP strings are already binary-safe. The
practical rule: hand bytes straight from file_get_contents() (or your
storage layer) to the extension and back, with no encoding layer in between.
Threading and SAPI posture
The extension is built and tested for PHP 8.3–8.5 NTS on Linux (x86_64,
arm64) and macOS (arm64); ZTS is declared supported but not yet exercised in
CI (no ZTS runner in the release matrix). It holds no global mutable state: each
Reader/Builder/Signer is an independent value, so concurrent requests
in any SAPI are fine.
Error philosophy
One exception class (C2paException), typed error variants internally, and
a hard line between verdicts and errors: an unsigned image or a failed
validation is a verdict (a normal return), while corrupt input or a
misconfigured signer is an error (an exception). See Errors.
Versioning and releases
The crate follows semver via git tags (v*); each tag’s CI run builds the
full platform × PHP-minor matrix and attaches:
- PIE-convention extension tarballs per leg, and
- a transitive third-party license manifest (
cargo about) covering everything statically linked into the binaries.
Dependency versions are pinned exactly (=x.y.z) — upgrades are deliberate,
tested bumps, never silent drift.
Licensing
GPL-2.0-or-later, © Automattic, Inc. — consistent with WordPress core. The
“or later” clause is load-bearing: the c2pa and ext-php-rs dependencies
are Apache-2.0/MIT, and Apache-2.0 is GPLv3-compatible, so distributing the
combined work exercises the GPLv3 option.
Scaffolding
The repository is a child of
DisplaceTech/ext-template:
the build system, CI, docs, and release workflows are template-managed and
re-rendered with its bin/sync (driven by .ext-template.conf at the repo
root); the Rust sources, stubs, and this book are the extension’s own.
Errors
Every failure throws Automattic\VIP\C2PA\C2paException, which extends
\RuntimeException — existing catch (\RuntimeException $e) clauses keep
working, and a namespace-wide catch is one class.
use Automattic\VIP\C2PA\C2paException;
use Automattic\VIP\C2PA\Reader;
try {
$reader = Reader::fromBytes($bytes, 'image/jpeg');
} catch (C2paException $e) {
// corrupt asset or malformed manifest — not merely "unsigned"
}
What is not an error
The API draws a hard line between verdicts and errors:
- No manifest —
Reader::fromBytes()returns normally withhasManifest() === false. - Invalid manifest — a manifest that fails validation (tampered bytes,
bad signature) returns normally with
validationState() === "Invalid".
Exceptions are reserved for inputs and configuration that are actually broken.
Message taxonomy
Messages are prefixed by failure category:
| Prefix | Thrown by | Meaning |
|---|---|---|
c2pa read/validate failed: … | Reader::fromBytes() | The asset is corrupt or its manifest is structurally unreadable |
c2pa signing failed: … | Builder::sign() | Manifest assembly, ingredient embedding, or the signature itself failed |
invalid signer configuration: … | Signer::fromPem(), generateSelfSigned(), sign() | Unsupported algorithm, empty/invalid PEM, chain–key mismatch |
invalid input: … | Builder::addAssertion(), addIngredientFromBytes() | Caller-supplied JSON did not parse |
The prefixes are stable enough to branch on for logging/metrics, but prefer structuring your code so the call site tells you the category — each prefix maps 1:1 to the operation you invoked.