Skip to content

feat: Image Provenance experiment and CDN worker templates (C2PA image signing)#302

Draft
erik-sv wants to merge 10 commits intoWordPress:developfrom
erik-sv:feature/image-provenance-cdn
Draft

feat: Image Provenance experiment and CDN worker templates (C2PA image signing)#302
erik-sv wants to merge 10 commits intoWordPress:developfrom
erik-sv:feature/image-provenance-cdn

Conversation

@erik-sv
Copy link

@erik-sv erik-sv commented Mar 11, 2026

Summary

Adds an Image Provenance experiment that signs image attachments with C2PA manifests on upload and injects C2PA-Manifest-URL response headers for singular posts with featured images. Also adds CDN edge worker templates for Cloudflare, Lambda@Edge, and Fastly so CDN-served images can propagate the provenance header downstream.

Depends on #294 — reuses C2PA_Manifest_Builder, Local_Signer, and Signing_Interface from the Content Provenance namespace. Merge #294 first; the diff against develop will then show only the Image Provenance additions.

What this adds

  • Image_Provenance experiment — hooks add_attachment to sign JPEG/PNG/WebP/GIF uploads; stores C2PA manifest JSON and canonical URL in post meta
  • C2PA-Manifest-URL header injection — on singular pages with a signed featured image, injects the header pointing to the manifest REST endpoint
  • REST endpoints — lookup by URL and retrieve manifest by attachment ID
  • CDN worker templates — Cloudflare Worker (with KV caching), AWS Lambda@Edge, and Fastly Compute@Edge; all call the WordPress REST lookup endpoint
  • 10 integration tests covering upload signing, MIME filtering, REST lookup, manifest retrieval, header injection, and non-singular guard

Upload and signing flow

flowchart TD
    A["Media upload (JPEG, PNG, WebP, GIF)"] --> B["add_attachment hook"]
    B --> C{"Supported MIME type?"}
    C -->|No| D["Skip - no meta written"]
    C -->|Yes| E["wp_get_attachment_url()"]
    E --> F["Strip query params to get canonical URL"]
    F --> G["C2PA_Manifest_Builder build c2pa.created"]
    G --> H["Local_Signer RSA-2048 via OpenSSL"]
    H -->|Error| I["Store _c2pa_image_status = error"]
    H -->|Success| J["Write post meta"]
    J --> J1["_c2pa_image_manifest"]
    J --> J2["_c2pa_image_manifest_url"]
    J --> J3["_c2pa_image_canonical_url"]
    J --> J4["_c2pa_image_status = signed"]
Loading

Page header injection flow

flowchart LR
    A[Page request hits send_headers hook] --> B{is_singular?}
    B -->|No| C[Return — no header]
    B -->|Yes| D[get_post_thumbnail_id]
    D --> E{Has featured image?}
    E -->|No| C
    E -->|Yes| F[get_post_meta: _c2pa_image_manifest_url]
    F --> G{Manifest URL set?}
    G -->|No| C
    G -->|Yes| H[header: C2PA-Manifest-URL: REST URL]
Loading

REST lookup and CDN worker flow

flowchart TD
    A[CDN image request] --> B[Edge worker intercepts origin response]
    B --> C[Canonicalize image URL: strip query params]
    C --> D[GET /c2pa-provenance/v1/images/lookup?url=canonical]
    D --> E{Match found?}
    E -->|No| F[Pass through — no header added]
    E -->|Yes| G[Inject C2PA-Manifest-URL into response headers]

    H[GET /images/lookup?url=X] --> I[meta_query on _c2pa_image_canonical_url]
    I -->|Found| J[200: record_id + manifest_url]
    I -->|Not found| K[404: not_found]

    L[GET /images/manifest/id] --> M[get_post_meta: _c2pa_image_manifest]
    M -->|Found| N[200: manifest JSON]
    M -->|Not found| O[404: not_found]
Loading

Exact-URL limitation and upgrade path

These workers use exact URL matching (scheme + host + path, query params stripped). CDN transforms that rewrite the URL path (e.g. /cdn-cgi/image/width=800/photo.jpg) will not match the original upload URL.

For CDN-transform survival using perceptual hash (pHash) matching, use the Encypher free API which handles cross-CDN, multi-resolution image lookup at scale.

REST API reference

GET /wp-json/c2pa-provenance/v1/images/lookup?url=<canonical_url>
→ 200 { "record_id": "<id>", "manifest_url": "<rest_url>" }
→ 404 { "error": "not_found" }

GET /wp-json/c2pa-provenance/v1/images/manifest/<attachment_id>
→ 200 { "magic": "…", "version": 1, "claims": { … }, "signature": "…" }
→ 404 { "error": "not_found" }

CDN worker setup (Cloudflare example)

cp cdn-workers/cloudflare/wrangler.toml.template cdn-workers/cloudflare/wrangler.toml
# Edit: set WORDPRESS_REST_URL to your site's REST base
wrangler kv:namespace create "CDN_PROVENANCE_CACHE"
# Edit: paste the namespace ID into wrangler.toml
wrangler deploy

See cdn-workers/README.md for Lambda@Edge and Fastly instructions.

Files changed

File Type Description
includes/Experiments/Image_Provenance/Image_Provenance.php New Main experiment class
includes/Experiment_Loader.php Modified Register Image_Provenance
cdn-workers/cloudflare/cdn-provenance-worker.js New Cloudflare Worker with KV caching
cdn-workers/cloudflare/wrangler.toml.template New Wrangler config template
cdn-workers/lambda-edge/cdn-provenance-handler.mjs New Lambda@Edge Origin Response handler
cdn-workers/fastly/main.rs New Fastly Compute@Edge handler
cdn-workers/README.md New Setup instructions for all three platforms
.eslintignore New Exclude cdn-workers/ from WordPress ESLint rules
tsconfig.json Modified Exclude cdn-workers/** from TypeScript compilation
tests/Integration/…/Image_ProvenanceTest.php New 10 integration tests, 24 assertions

Test plan

  • Run composer test -- --filter Image_Provenance — all 10 tests pass
  • Upload a JPEG via Media Library → check _c2pa_image_status attachment meta is signed
  • GET /wp-json/c2pa-provenance/v1/images/lookup?url=<attachment-url> → returns record_id
  • GET /wp-json/c2pa-provenance/v1/images/lookup?url=<url>?w=800&format=webp → same record_id (CDN params stripped)
  • GET /wp-json/c2pa-provenance/v1/images/manifest/<id> → returns manifest JSON with claims and signature
  • View a singular post with signed featured image → response headers include C2PA-Manifest-URL
  • View a non-singular page (archive, home) → no C2PA-Manifest-URL header
  • Upload a PDF → _c2pa_image_status meta should NOT be set (MIME guard)
Open WordPress Playground Preview

Erik Svilich added 8 commits March 11, 2026 20:18
Implements the C2PA Content Provenance experiment per the WordPress/ai
experiment framework pattern. Zero external dependencies in default
configuration. Fully air-gap compatible with local signing.

## What's included

**Experiment class** (includes/Experiments/Content_Provenance/)
- Content_Provenance extends Abstract_Experiment (id: content-provenance,
  category: EDITOR)
- Auto-signs posts on publish_post (c2pa.created) and post_updated
  (c2pa.edited) with provenance chain ingredient references
- 7 settings: signing_tier, connected_service_url/api_key, byok_certificate,
  auto_sign, show_badge, badge_position
- REST: POST /c2pa-provenance/v1/verify (public),
  GET /c2pa-provenance/v1/status (editor)
- /.well-known/c2pa discovery endpoint per C2PA 2.x §6.4
- Optional verification badge on published posts

**Signing engine** (Signing/)
- Signing_Interface contract
- Local_Signer: RSA-2048 keypair, zero setup
- Connected_Signer: delegates to any C2PA-compliant HTTP service
- BYOK_Signer: publisher's own PEM certificate

**C2PA core**
- C2PA_Manifest_Builder: full C2PA 2.3 manifest with provenance chain
- Unicode_Embedder: VS1-VS256 per C2PA §A.7

**WordPress Abilities API**
- c2pa/sign and c2pa/verify registered as first-class abilities

**Gutenberg sidebar**
- 5-state trust-tier-aware shield badge
- Sign Now, Verify, trust tier notice for local signing

**Tests**: 11 integration tests + 2 ability tests
**Docs**: user guide + developer reference

Out of scope: sentence-level signing, coalition, image provenance, AI output
signing (separate PRs per the three-part contribution plan).

Ref: C2PA 2.3 §A.7
- Fix Unicode_Embedder VS17-VS256 encoding: 3rd byte must cycle through
  0x84-0x87 (not a fixed 0x84) so continuation bytes stay in 0x80-0xBF.
  Fixes preg_replace /u returning null on invalid UTF-8 in strip()
- Replace str_starts_with() (PHP 8.0) with strncmp() for PHP 7.4 compat
- Remove native `mixed` return type and `array|WP_Error` union type
  (PHP 8.0 native types) from Content_Provenance methods; keep in PHPDoc
- Add public const visibility to MAGIC, VERSION, PREFIX, QUERY_VAR constants
- Remove unused Signing_Hooks_Trait (logic already inlined in main class)
- Add get_public_signer() so the c2pa/sign Ability can access the signer
- Replace all short ternaries (?:) with explicit ternaries per WPCS
- Remove error_log() calls (not allowed in production per WPCS)
- Fix openssl_pkey_new() false-check before calling export/get_details
- Drop Connected_Signer timeout from 15s to 3s per VIP performance standard
- Fix REST /status permission_callback to use $request->get_param()
  instead of $_GET (removes nonce-verification PHPCS warning)
- Fix JS: add curly braces after if-conditions, fix i18n flanking whitespace,
  fix Prettier formatting
- All 11 Content_Provenance integration tests + 2 ability tests pass
- PHPStan level 8: [OK] No errors; PHPCS: 0 errors 0 warnings
Unicode_Embedder now implements the C2PA 2.3 §A.7 C2PATextManifestWrapper
format exactly as specified:
- Wrapper is APPENDED to NFC-normalized text (not prepended)
- Binary header: 8-byte magic "C2PATXT\0" + 1-byte version + 4-byte
  big-endian manifest length, encoded as variation selectors
- extract() scans for U+FEFF anywhere in the string, validates the
  binary header (magic + version + length), and reads the manifest bytes
- strip() removes U+FEFF and VS1-VS256 code points from anywhere in text
  using a Unicode-aware regex

Also fixes pre-existing REST route test failures in Example_ExperimentTest
by resetting the REST server after experiment initialization in setUp(),
ensuring rest_api_init hooks registered during initialize_experiments()
are captured before test methods run.
Add comprehensive integration tests for all previously-uncovered code paths
to improve patch coverage from 26% toward the project target.

New test files:
- C2PA_Verify_Test.php: 8 tests for verify ability (empty text, unsigned, verified, schema)
- Local_SignerTest.php: 4 tests for local RSA signing and key embedding
- Connected_SignerTest.php: 6 tests for remote signer with HTTP mock via pre_http_request filter
- BYOK_SignerTest.php: 5 tests for bring-your-own-key PEM file signing
- Verification_BadgeTest.php: 8 tests for badge rendering, archive exclusion, and tier labels

Expanded test files:
- Content_ProvenanceTest.php: +18 tests for sign_post, publish/update hooks, keypair lifecycle,
  get_public_signer tier selection, REST endpoints, and settings registration
- C2PA_Sign_Test.php: +6 tests for permissions, non-array input, schema validation
Add Unicode_EmbedderTest.php with 9 tests covering all extract() boundary
cases: insufficient bytes, wrong magic, wrong version, zero-length manifest,
FEFF without VS bytes, and strip() edge cases.

Expand Content_ProvenanceTest.php with 11 more tests covering:
render_settings_fields() HTML output (default, connected-tier, byok-tier),
add_well_known_rewrite() query var registration,
handle_well_known_request() early-return path,
enqueue_assets() all three branches (no screen, non-post screen, post screen),
C2PA_Manifest_Builder::build() signer-error path, and
C2PA_Manifest_Builder::extract_and_verify() invalid-JSON path.

Expand C2PA_Sign_Test.php with 2 more tests covering:
get_experiment() filter branch (experiment provided via filter) and
the is_wp_error() path when the signer fails.
Adds a new Image_Provenance experiment that signs JPEG/PNG/WebP/GIF
attachments with C2PA manifests on upload and injects C2PA-Manifest-URL
response headers on singular pages with a featured image.

REST endpoints under c2pa-provenance/v1:
  GET /images/lookup?url=  — exact URL match (strips CDN transform params)
  GET /images/manifest/{id} — returns stored manifest JSON

CDN worker templates (Cloudflare, Lambda@Edge, Fastly) call the
/images/lookup endpoint to inject C2PA-Manifest-URL headers at the edge.
Exact-URL matching only; CDN-transform survival (pHash) is out of scope
for this open-source release.

Excludes cdn-workers/ from WordPress ESLint and TypeScript checks as
these are edge-runtime files targeting non-browser environments.
- Remove broken Connected/BYOK signer branches — no credentials UI exists
  for those tiers; always use Local_Signer for image manifests
- Remove unused signing_tier option registration
- Eliminate N+1 query in rest_lookup_callback by storing stripped canonical
  URL in _c2pa_image_canonical_url at signing time and using meta_query for
  O(1) lookup instead of loading all IDs and calling wp_get_attachment_url()
  per attachment
- Strip query params from attachment URL at signing time so stored canonical
  is consistent with the lookup canonical (fixes edge case in test envs where
  wp_get_attachment_url() returns URLs with query params)
@codecov
Copy link

codecov bot commented Mar 11, 2026

Codecov Report

❌ Patch coverage is 84.85370% with 176 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.92%. Comparing base (078c9f0) to head (01a0f2b).
⚠️ Report is 14 commits behind head on develop.

Files with missing lines Patch % Lines
...eriments/Content_Provenance/Content_Provenance.php 89.19% 51 Missing ⚠️
.../Experiments/Image_Provenance/Image_Provenance.php 76.08% 44 Missing ⚠️
...eriments/Content_Provenance/Well_Known_Handler.php 0.00% 31 Missing ⚠️
...ncludes/Abilities/Content_Provenance/C2PA_Sign.php 85.26% 14 Missing ⚠️
...riments/Content_Provenance/Signing/BYOK_Signer.php 72.34% 13 Missing ⚠️
...iments/Content_Provenance/Signing/Local_Signer.php 72.97% 10 Missing ⚠️
...ments/Content_Provenance/C2PA_Manifest_Builder.php 94.73% 4 Missing ⚠️
...ts/Content_Provenance/Signing/Connected_Signer.php 92.45% 4 Missing ⚠️
...eriments/Content_Provenance/Verification_Badge.php 95.34% 2 Missing ⚠️
...ludes/Abilities/Content_Provenance/C2PA_Verify.php 98.14% 1 Missing ⚠️
... and 2 more
Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #302      +/-   ##
=============================================
+ Coverage      57.72%   65.92%   +8.19%     
- Complexity       567      776     +209     
=============================================
  Files             36       49      +13     
  Lines           2933     4117    +1184     
=============================================
+ Hits            1693     2714    +1021     
- Misses          1240     1403     +163     
Flag Coverage Δ
unit 65.92% <84.85%> (+8.19%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Add 9 integration tests covering previously untested branches:
- register_settings() option registration
- render_settings_fields() HTML output
- sign_on_attachment_upload() with empty mime type (early return)
- sign_on_attachment_upload() when wp_get_attachment_url returns false (error status)
- sign_on_attachment_upload() when C2PA signing fails with invalid key (error status)
- get_local_keypair() stored-keypair path (no regeneration)
- inject_manifest_url_header() with no featured image (early return)
- inject_manifest_url_header() with unsigned attachment (no manifest_url)
- rest_manifest_callback() with unsigned attachment (404)
@jeffpaul jeffpaul added this to the 0.6.0 milestone Mar 11, 2026
@jeffpaul jeffpaul added the [Type] Enhancement New feature or request label Mar 11, 2026
- Remove dead `empty( \$canonical )` branch (PHPStan: always non-falsy string)
- Fix extra blank line before closing brace of register_settings()
- Align \$parsed assignment to match \$canonical_url (PHPCS alignment)
- Move meta_query phpcs:ignore to the meta_query line itself
@erik-sv erik-sv force-pushed the feature/image-provenance-cdn branch from 335715c to 01a0f2b Compare March 12, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Type] Enhancement New feature or request

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

2 participants