provemark/content-credentials

PHP library for C2PA Content Credentials: build, sign, read and verify manifests, with machine-readable marking of AI-generated content (EU AI Act, Article 50).

Maintainers

Package info

github.com/provemark/content-credentials

pkg:composer/provemark/content-credentials

Transparency log

Statistics

Installs: 39

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.12.0 2026-08-13 11:39 UTC

README

CI Latest Version PHP Version License: MIT

A PHP library for C2PA Content Credentials: build, sign, read and verify manifests for media assets. Its primary purpose is the machine-readable marking of AI-generated content required by the EU AI Act, Article 50 — a c2pa.actions.v2 / c2pa.created assertion with digitalSourceType = trainedAlgorithmicMedia.

Article 50(2) covers content that is generated or manipulated, and both are supported. Marking manipulation takes one extra argument — the original asset — because C2PA records an edit as a c2pa.opened action pointing at an ingredient whose hash covers the original's bytes, not a filename or a digest you can supply instead. See What you can mark.

It ships as two pieces:

  • a framework-agnostic Core (Provemark\ContentCredentials\Core\*) that builds manifests and talks to a signing service over HTTP (PSR-18), and
  • an optional Laravel integration (Provemark\ContentCredentials\Laravel\*) — a service provider + facade that wires everything from config.

The private signing key never touches your web application. Signing is delegated to a small Node signing service (service/, based on @contentauth/c2pa-node) that you run separately — keeping the signing key isolated from the app process. (This is the deliberate trade-off versus an in-process native extension, which puts the key on the web server.)

Reading needs none of that. Extracting the C2PA metadata from a file you did not sign — inspecting what the credential claims, checking whether an image is marked as AI-generated, verifying the signature — needs no private key and no certificate. With the in-process reader installed it needs no service either: see Reading and verifying.

Listed as the PHP library under External projects in the Content Authenticity Initiative's community resources. That is a listing, not a conformance claim — see Going to production for what the C2PA Conformance Program covers and why no library can appear on the Conforming Products List.

Status: this is a spec-driven rebuild of a proven end-to-end spike. The design, decisions and trade-offs are documented in specs/, docs/ and NOTES.md.

Requirements

  • PHP 8.3+
  • Laravel 11, 12 or 13 — only if you use the service provider, facade, jobs or artisan commands. The core library is framework-agnostic and needs no Laravel at all; illuminate/* is never a runtime dependency of this package. Each major is covered by CI.
  • A PSR-18 HTTP client and PSR-17 factories. In Laravel these are discovered automatically (Guzzle ships with Laravel); in plain PHP you inject your own.
  • The signing service running — for signing only (see Running the signing service). Reading and verifying work without it; see Reading and verifying.

Quickstart

Ten minutes from nothing to a signed image you can verify. Two pieces are involved: a signing service that holds the private key, and the PHP library that talks to it. The service comes first — without it, the library has nothing to call.

1. Run the signing service

It lives in this repository, not in the Composer package, so clone the repo:

git clone https://github.com/provemark/content-credentials.git
cd content-credentials

cp .env.example .env
# Generate a shared secret and put it in .env as CONTENTAUTH_API_KEY:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# The private test key is deliberately not committed. Fetch the c2pa-rs sample —
# test material only, never a real key:
curl -sfSL https://raw.githubusercontent.com/contentauth/c2pa-rs/main/cli/sample/es256_private.key \
  -o certs/es256_private.key

docker compose up -d --build
curl -s http://127.0.0.1:3000/health

You should see {"status":"ok","signing_alg":"es256",...}. If not, stop here — nothing below will work.

2. Install the library in your application

composer require provemark/content-credentials

In Laravel the service provider and facade register automatically. Point it at the service with the same secret you generated above:

CONTENTAUTH_SERVICE_URL=http://localhost:3000
CONTENTAUTH_API_KEY=the-value-from-your-.env

3. Sign an image

use Provemark\ContentCredentials\Core\Manifest\ManifestBuilder;
use Provemark\ContentCredentials\Core\Manifest\MediaType;
use Provemark\ContentCredentials\Core\Signing\Asset;
use Provemark\ContentCredentials\Laravel\ContentCredentials;

$manifest = ManifestBuilder::forAiGenerated(MediaType::Png)
    ->withSoftwareAgent('ACME GenAI Image Model', '3.1.0')
    ->build();

$signed = ContentCredentials::sign(
    new Asset(file_get_contents('image.png'), MediaType::Png),
    $manifest,
);

file_put_contents('signed.png', $signed->bytes);

⚠️ Write those bytes as they are. Any re-encode, resize, optimiser or CDN image transform invalidates the credential — the signature covers the file's bytes. This is the single most common way a working integration breaks, and it fails silently: the image still displays, the credential is simply gone.

4. Check that it worked

$report = ContentCredentials::read(new Asset($signed->bytes, MediaType::Png));

$report->isVerifiedAiGenerated();  // true — marked AND the signature checked out
$report->signer()?->issuer;        // "C2PA Test Signing Cert"

Or from the repository, using the authoritative tool:

bin/verify.sh signed.png
Signed by      : C2PA Test Signing Cert / CN=C2PA Signer [Es256]
Signature valid: PASS (claimSignature.validated)
Cert trusted   : PASS (signingCredential.trusted)
AI Art.50 mark : PASS (digitalSourceType=trainedAlgorithmicMedia)
Remaining status/failures: none

Cert trusted: PASS here means the bundled test anchors trust the bundled test certificate — bin/verify.sh passes them to c2patool deliberately. A public verifier, using the production trust list, will say untrusted. That is correct and expected; see below.

What you have, and what you do not

The signature is cryptographically valid, and the image carries the EU AI Act Article 50 marking: a c2pa.actions.v2 assertion with digitalSourceType = trainedAlgorithmicMedia.

What you do not have yet is a certificate anyone else trusts. The bundled one is c2pa-rs test material — public verifiers will report the signature as valid and the certificate as untrusted. Replacing it is the one step between this and production; see Going to production.

Reading C2PA metadata from an existing file

Extracting and inspecting the C2PA metadata in a file — yours or anyone's — needs no key, no certificate and, with the in-process reader, no service:

pie install ericmann/ext-c2pa      # https://github.com/php/pie
use Provemark\ContentCredentials\Core\Manifest\MediaType;
use Provemark\ContentCredentials\Core\Reading\ExtC2paReader;
use Provemark\ContentCredentials\Core\Signing\Asset;

$report = (new ExtC2paReader)->read(
    new Asset(file_get_contents('photo.jpg'), MediaType::Jpeg),
);

$report->hasManifest();       // false when the file carries no credential
$report->isAiGenerated();     // marked as trainedAlgorithmicMedia
$report->isSignatureValid();  // the signature checks out
$report->softwareAgents();    // which system says it made this

A file with no credential is not an error: hasManifest() returns false and the rest of the report answers accordingly. Reading through the signing service instead needs no extension and is the default; in Laravel either one is a facade call, plus php artisan content-credentials:read <file>. Which engine answers, how to check the certificate against a trust list, and what each route costs is on Reading and verifying.

Where the rest lives

The quickstart above is the whole of the happy path. Everything else has its own page, so this one stays readable:

Page What is on it
Usage Building manifests, signing and reading — Laravel and plain PHP, configuration, the facade, jobs and commands
What you can mark The thirteen media types, the digitalSourceType terms, what each one actually claims, and marking manipulated content
Reading and verifying Reading C2PA metadata with or without the signing service, binding the in-process reader, trust anchors, and the trade-off between the two
Running the signing service Audit logging, rate limits, sizing the container, assertion limits, rotating the key
Going to production Certificates a public verifier will trust, trust-list verification, C2PA Conformance Program alignment
Stability and support What is public API, which PHP and Laravel versions are supported, the deprecation policy, and what 1.0 would require

Deeper background: docs/c2pa-primer.md for the domain rules this package is built on, and docs/adr/ for the decisions that shaped it.

Verifying the output

bin/verify.sh runs c2patool with the test trust settings and reports signature validity, cert trust and the AI marking:

bin/verify.sh out/signed.png
# Signature valid: PASS   Cert trusted: PASS   AI Art.50 mark: PASS

Note: test certificates produce a cryptographically valid signature but are not on any production trust list — "valid signature" is not the same as "trusted certificate". See docs/c2pa-primer.md §5.

Development

composer install
composer check   # Pint (style) + PHPStan (level max) + Pest + Deptrac

composer check is the single definition of green. The architecture boundary (Core must not depend on Laravel/Illuminate) is enforced by Deptrac.

To exercise the whole chain against a running service with the real library code (build → sign → read → c2patool verify):

docker compose up -d --build   # service must be running (see above)
php bin/e2e.php                 # signs tests/fixture.png -> out/signed.png, then verifies

Security

  • Never commit real private keys or production certificates. certs/ and the tests use c2pa-rs test material only; es256_private.key is gitignored. Trust-settings files contain only public test CA certs.

  • The CONTENTAUTH_API_KEY and service URL come from the environment; the library never logs the token or key material.

  • All manifest/service input is treated as untrusted and validated.

  • The signing service publishes on 127.0.0.1 only. It speaks plain HTTP and holds the signing key, so it must not be exposed directly. To reach it from another host, put TLS termination in front of it and restrict the network path — do not simply widen the port binding in docker-compose.yml.

  • Treat CONTENTAUTH_API_KEY as equivalent to the signing key. Anyone who can call /v1/sign can have assertions signed by your certificate. The service constrains what it will attest to (see below), but it cannot tell an authorised caller from a stolen token. Rotate it like a key, scope it per application, and never share one token across environments.

  • One service, one caller. The service authenticates a single shared token, so everything derived from it is shared too. Audit records identify which token signed something, not which application — with one token that is the same value on every record. The rate limit is likewise one budget for everyone holding it.

    That is fine for the common case of one application per service. It bites the moment two callers share an instance — staging and production pointed at the same service is the usual way this happens, because certificates are not cheap enough to duplicate. Then a runaway job in staging spends production's budget, rotating the token stops both at once, and a leak from either is a leak from both. If that describes your setup, run a service per caller, or open an issue — named per-client credentials are specified and waiting for a real deployment to shape them.

  • Verify before you trust what you read. isAiGenerated(), signer() and digitalSourceTypes() report what a manifest claims — they do not imply the signature checked out. Gate on isSignatureValid() (and isTrusted() where trust matters) before acting on a credential.

License

MIT © Maurice van Loon