How to read the C2PA metadata out of a media file in PHP — extract what the credential claims, inspect the assertions, check whether the asset is marked as AI-generated, and verify the signature. Verification does not need the signing service. This page covers both readers, how to bind the in-process one in Laravel, and the trade-off between them. Back to the README.
Verification needs no signing service. It needs no private key and no
certificate either — checking a credential is a function of the asset bytes plus,
optionally, a trust list. Until now it needed a service anyway, because reading
and signing shared one transport. ExtC2paReader removes that.
pie install ericmann/ext-c2pa # https://github.com/php/pieuse Provemark\ContentCredentials\Core\Reading\ExtC2paReader;
$reader = new ExtC2paReader($anchorsPem); // PEM contents, not a path
$report = $reader->read(new Asset($bytes, MediaType::Png));
$report->isVerifiedAiGenerated(); // no HTTP, no service, no keyBoth readers implement ReaderInterface and return the same ManifestReport,
so choosing between them is an installation decision, not an API one:
$reader = ExtC2paReader::isAvailable()
? new ExtC2paReader($anchorsPem)
: new SigningServiceReader($client, $factory, $factory, $config);Construction throws ExtensionMissingException when the extension is absent. It
deliberately does not fall back to the service reader: a caller who asked for
in-process reading and silently got HTTP cannot tell, and the fallback would need
a URL and token they never supplied.
Set the mode in config/content-credentials.php; everything resolved from the
container — the facade, the jobs, the artisan commands — follows it.
CONTENTAUTH_READER=auto # service (default) | extension | auto
CONTENTAUTH_TRUST_ANCHORS=/path/to/anchors.pem # or the PEM contentsservice, so installing the extension does nothing until you
set this. That is deliberate. The two readers run different c2pa-rs versions,
and an extension installed for an unrelated reason should not silently change
which engine decides your trust verdicts. auto is the setting most people want
— but as a choice you made, not one that happened to you.
One property of auto worth knowing: it picks the extension whenever the
extension is loaded, without considering the media type. That is harmless
today, because both engines sign and read all thirteen supported types. It would
stop being harmless if a format were ever readable by one engine and not the
other — which is the situation a capability method would exist for, and which
does not exist yet. See Stability and support for why that is
left unbuilt.
php artisan content-credentials:read <file> prints the engine it resolved
and what the configuration asked for, so "which engine produced this report,
and was that a choice or a detection?" is answerable without reading config:
reader : extension (configured: auto)
hasManifest : true
CONTENTAUTH_TRUST_ANCHORS accepts PEM contents or a path — a path is read
for you, because every trust surface underneath this one takes contents and
silently verifies nothing when handed a path.
It applies to the extension reader only. The service reader's trust
verification is configured on the service, through CONTENTAUTH_TRUST_SETTINGS.
Same concept, two places: if you set CONTENTAUTH_TRUST_ANCHORS and the service
reader still reports isTrusted() false, that is why.
ericmann/ext-c2pais atv0.1.0. It is an Automattic VIP product built for a WordPress plugin, not neutral infrastructure, and its API may move. The adapter is the containment: a break is one class to fix, and callers see nothing.- The two readers run different engines. The extension carries c2pa-rs
0.89.0; the signing service carries 0.90.16. They agree today — an
integration test compares both readers accessor by accessor on the same asset,
and that test is what would tell us they had stopped. Run it with
vendor/bin/pest --group=SPEC-019before relying on a mixed setup. - Signing still goes through the service. The extension can sign too, and this library does not expose that: it would put the private key in your web process, which is the one thing this architecture exists to avoid. Reading in-process while signing through the service is a supported, and probably the best, combination.
Both readers return the same ManifestReport. It answers four different
questions, and keeping them apart is the point — a file can be marked as
AI-generated by a signature nobody trusts.
| Question | Accessor |
|---|---|
| Is there a credential at all? | hasManifest(), activeManifestLabel() |
| Does it claim AI? | isAiGenerated(), involvesGenerativeAi(), digitalSourceTypes(), softwareAgents() |
| Does the cryptography hold? | isSignatureValid(), hasTimestamp(), validationState(), validationStatusCodes() |
| Do you trust who signed it? | isTrusted(), signer() |
| Which rules does it claim to follow? | declaredSpecVersion() |
One inherited exception to that declaration. Manifests signed by this package declare
2.4.0, and satisfy it — with one departure we do not control. The engine generates a thumbnail of the asset and places it in the manifest'sgathered_assertions, the field C2PA defines for assertions "provided to the claim generator by other components in the workflow". That thumbnail was made by the generator, so the placement contradicts what the field means. It is c2pa-rs's default, it affects every tool built on it includingc2patool, and upstream tracks it as c2pa-rs #2106 with a fix that moves the thumbnail rather than removes it. We keep the thumbnail and record the exception rather than delete a useful feature to make a declaration look tidier.
isVerifiedAiGenerated() is the two middle columns at once — marked and the
signature checked out — which is usually the one you want.
Two distinctions that catch people out:
isAiGenerated()means exactlytrainedAlgorithmicMedia, generative AI output.involvesGenerativeAi()is the wider question; it is false foralgorithmicMedia, which is an algorithm but not a trained model. See What you can mark.- A valid signature is not a trusted certificate.
isSignatureValid()can be true whileisTrusted()is false, and for test certificates it always is. Trust is a separate check against anchors you supply.
A file with no credential is not an error. Reading an ordinary photo gives a
report where hasManifest() is false and the rest answers accordingly — no
exception, nothing to catch. Most files on the internet are this case.
SigningServiceReader sends the asset to the service; ExtC2paReader parses it
in-process through ext-c2pa. The usual comparison is operational — no second
process, no network hop, faster — and that is real. There is a second difference
worth deciding deliberately.
The extension parses untrusted input inside your application process. A manifest arrives as bytes from somewhere you do not control, and verifying it means parsing a container format in native code. With the service reader that parsing happens in a separate, disposable process; with the extension it happens in the PHP worker that also holds your session data and your database connections.
This is the mirror image of the argument in ADR-0003: the signing key is kept out of the web process by putting the signer behind a service, and the extension reader moves parsing in the opposite direction. Neither is wrong — a memory-safety bug in c2pa-rs is not a thing anyone has demonstrated — but the trade is worth making on purpose rather than inheriting it because an extension happened to be installed.
That is also why reader defaults to service and why auto has to be chosen
explicitly (SPEC-020): installing the extension for an unrelated reason should
not silently move where hostile input is parsed.