feat: Image Provenance experiment and CDN worker templates (C2PA image signing)#302
Draft
erik-sv wants to merge 10 commits intoWordPress:developfrom
Draft
feat: Image Provenance experiment and CDN worker templates (C2PA image signing)#302erik-sv wants to merge 10 commits intoWordPress:developfrom
erik-sv wants to merge 10 commits intoWordPress:developfrom
Conversation
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 Report❌ Patch coverage is 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
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)
- 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
335715c to
01a0f2b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an Image Provenance experiment that signs image attachments with C2PA manifests on upload and injects
C2PA-Manifest-URLresponse 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.What this adds
Image_Provenanceexperiment — hooksadd_attachmentto sign JPEG/PNG/WebP/GIF uploads; stores C2PA manifest JSON and canonical URL in post metaC2PA-Manifest-URLheader injection — on singular pages with a signed featured image, injects the header pointing to the manifest REST endpointUpload 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"]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]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]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
CDN worker setup (Cloudflare example)
See
cdn-workers/README.mdfor Lambda@Edge and Fastly instructions.Files changed
includes/Experiments/Image_Provenance/Image_Provenance.phpincludes/Experiment_Loader.phpImage_Provenancecdn-workers/cloudflare/cdn-provenance-worker.jscdn-workers/cloudflare/wrangler.toml.templatecdn-workers/lambda-edge/cdn-provenance-handler.mjscdn-workers/fastly/main.rscdn-workers/README.md.eslintignorecdn-workers/from WordPress ESLint rulestsconfig.jsoncdn-workers/**from TypeScript compilationtests/Integration/…/Image_ProvenanceTest.phpTest plan
composer test -- --filter Image_Provenance— all 10 tests pass_c2pa_image_statusattachment meta issignedGET /wp-json/c2pa-provenance/v1/images/lookup?url=<attachment-url>→ returnsrecord_idGET /wp-json/c2pa-provenance/v1/images/lookup?url=<url>?w=800&format=webp→ samerecord_id(CDN params stripped)GET /wp-json/c2pa-provenance/v1/images/manifest/<id>→ returns manifest JSON withclaimsandsignatureC2PA-Manifest-URLC2PA-Manifest-URLheader_c2pa_image_statusmeta should NOT be set (MIME guard)