diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..77920c88 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +build +node_modules +vendor +cdn-workers/ diff --git a/cdn-workers/README.md b/cdn-workers/README.md new file mode 100644 index 00000000..93ec92a1 --- /dev/null +++ b/cdn-workers/README.md @@ -0,0 +1,67 @@ +# CDN Provenance Workers + +These workers inject `C2PA-Manifest-URL` response headers for image requests, +enabling CDN-level content provenance verification. + +## How It Works + +1. An image is uploaded to WordPress -- the Image Provenance experiment signs it with a C2PA manifest. +2. The manifest URL is stored in attachment meta (`_c2pa_image_manifest_url`). +3. The CDN worker intercepts image responses, queries the WordPress REST API for the manifest URL, and injects `C2PA-Manifest-URL` into the response header. +4. Consumers (browsers, C2PA validators) can follow the header to verify image origin. + +## Limitation: Exact URL Matching Only + +These workers use **exact URL matching**. If your CDN transforms image URLs +(e.g. `/cdn-cgi/image/width=800/photo.jpg`), the lookup will not match the +original upload URL and no header will be injected. + +For CDN-transform survival using perceptual hash (pHash) matching, use the +**[Encypher free API](https://encypherai.com)** -- it handles cross-CDN, multi- +resolution image lookup at scale. + +## Cloudflare Worker + +### Setup + +1. Copy `cloudflare/wrangler.toml.template` to `cloudflare/wrangler.toml` +2. Set `WORDPRESS_REST_URL` to your WordPress site's REST API base URL +3. Create a KV namespace: `wrangler kv:namespace create "CDN_PROVENANCE_CACHE"` +4. Update the `id` in `wrangler.toml` with the namespace ID +5. Deploy: `wrangler deploy` + +### Local Testing + +```bash +wrangler dev +``` + +## AWS Lambda@Edge + +### Setup + +1. Set the `WORDPRESS_REST_URL` environment variable in your Lambda function config +2. Deploy as a CloudFront Lambda@Edge function (Origin Response trigger) +3. Ensure the Lambda has outbound internet access to reach your WordPress REST API + +### Local Testing + +```bash +# Using the SAM CLI +sam local invoke --event test-event.json +``` + +## Fastly Compute + +### Setup + +1. Create an Edge Dictionary named `wordpress_rest_url` +2. Add key `wordpress_rest_url` with your WordPress REST API base URL as value +3. Add a backend named `wordpress_api` pointing to your WordPress host +4. Build and deploy: `fastly compute build && fastly compute deploy` + +### Local Testing + +```bash +fastly compute serve +``` diff --git a/cdn-workers/cloudflare/cdn-provenance-worker.js b/cdn-workers/cloudflare/cdn-provenance-worker.js new file mode 100644 index 00000000..75ec1ee5 --- /dev/null +++ b/cdn-workers/cloudflare/cdn-provenance-worker.js @@ -0,0 +1,74 @@ +/** + * Cloudflare Worker: C2PA Image Provenance + * + * Looks up the C2PA manifest URL for any image request via the WordPress + * REST API and injects a C2PA-Manifest-URL response header. + * + * Configuration (wrangler.toml): + * WORDPRESS_REST_URL = "https://your-site.com/wp-json" + * CDN_PROVENANCE_CACHE = KV namespace binding + * + * For CDN-transform survival (pHash matching across resized images), + * use the Encypher free API: https://encypherai.com + */ + +export default { + async fetch(request, env) { + const response = await fetch(request); + + // Only process image responses. + const contentType = response.headers.get('content-type') || ''; + if (!contentType.startsWith('image/')) { + return response; + } + + const url = new URL(request.url); + // Canonical URL: scheme + host + path (strip CDN transform params). + const canonicalUrl = `${url.protocol}//${url.hostname}${url.pathname}`; + const cacheKey = `manifest:${canonicalUrl}`; + + // Try KV cache first. + let manifestUrl = null; + if (env.CDN_PROVENANCE_CACHE) { + manifestUrl = await env.CDN_PROVENANCE_CACHE.get(cacheKey); + } + + if (!manifestUrl) { + // Look up via WordPress REST API. + const lookupUrl = `${env.WORDPRESS_REST_URL}/c2pa-provenance/v1/images/lookup?url=${encodeURIComponent(canonicalUrl)}`; + + try { + const lookupResponse = await fetch(lookupUrl, { + headers: { 'Accept': 'application/json' }, + }); + + if (lookupResponse.ok) { + const data = await lookupResponse.json(); + manifestUrl = data.manifest_url || null; + + // Cache the result. + if (manifestUrl && env.CDN_PROVENANCE_CACHE) { + await env.CDN_PROVENANCE_CACHE.put(cacheKey, manifestUrl, { expirationTtl: 3600 }); + } + } + } catch (e) { + // Lookup failed — serve original response without header. + return response; + } + } + + if (!manifestUrl) { + return response; + } + + // Inject the header into a new response. + const newHeaders = new Headers(response.headers); + newHeaders.set('C2PA-Manifest-URL', manifestUrl); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + }, +}; diff --git a/cdn-workers/cloudflare/wrangler.toml.template b/cdn-workers/cloudflare/wrangler.toml.template new file mode 100644 index 00000000..48e03c7b --- /dev/null +++ b/cdn-workers/cloudflare/wrangler.toml.template @@ -0,0 +1,13 @@ +name = "cdn-provenance-worker" +main = "cdn-provenance-worker.js" +compatibility_date = "2024-01-01" + +[vars] +WORDPRESS_REST_URL = "https://YOUR_WORDPRESS_SITE/wp-json" + +# KV namespace for caching manifest URL lookups. +# Create with: wrangler kv:namespace create "CDN_PROVENANCE_CACHE" +# Then replace the id below with the output. +[[kv_namespaces]] +binding = "CDN_PROVENANCE_CACHE" +id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" diff --git a/cdn-workers/fastly/main.rs b/cdn-workers/fastly/main.rs new file mode 100644 index 00000000..eaae5ccc --- /dev/null +++ b/cdn-workers/fastly/main.rs @@ -0,0 +1,70 @@ +//! Fastly Compute: C2PA Image Provenance +//! +//! Injects a C2PA-Manifest-URL header into image responses by looking up +//! the manifest via the WordPress REST API. +//! +//! Edge Dictionary key: `wordpress_rest_url` +//! Value: https://your-site.com/wp-json +//! +//! For CDN-transform survival (pHash matching), use Encypher free API: +//! https://encypherai.com + +use fastly::http::{Method, StatusCode}; +use fastly::{Error, Request, Response}; + +#[fastly::main] +fn main(req: Request) -> Result { + let backend = "origin"; + let mut beresp = req.send(backend)?; + + // Only process image responses. + let content_type = beresp + .get_header_str("content-type") + .unwrap_or("") + .to_string(); + + if !content_type.starts_with("image/") { + return Ok(beresp); + } + + // Get WordPress REST URL from Edge Dictionary. + let dict = fastly::Dictionary::open("wordpress_rest_url"); + let wp_rest_url = match dict.get("wordpress_rest_url") { + Some(url) => url, + None => return Ok(beresp), + }; + + // Canonical URL: scheme + host + path (no query string). + let req_url = req.get_url(); + let canonical_url = format!( + "{}://{}{}", + req_url.scheme(), + req_url.host_str().unwrap_or(""), + req_url.path() + ); + + let encoded_url = urlencoding::encode(&canonical_url); + let lookup_url = format!( + "{}/c2pa-provenance/v1/images/lookup?url={}", + wp_rest_url.trim_end_matches('/'), + encoded_url + ); + + // Look up the manifest URL. + let lookup_req = Request::get(lookup_url); + let lookup_resp = lookup_req.send("wordpress_api"); + + if let Ok(mut resp) = lookup_resp { + if resp.get_status() == StatusCode::OK { + if let Ok(body) = resp.take_body_str() { + if let Ok(json) = serde_json::from_str::(&body) { + if let Some(manifest_url) = json["manifest_url"].as_str() { + beresp.set_header("C2PA-Manifest-URL", manifest_url); + } + } + } + } + } + + Ok(beresp) +} diff --git a/cdn-workers/lambda-edge/cdn-provenance-handler.mjs b/cdn-workers/lambda-edge/cdn-provenance-handler.mjs new file mode 100644 index 00000000..4e5650cc --- /dev/null +++ b/cdn-workers/lambda-edge/cdn-provenance-handler.mjs @@ -0,0 +1,64 @@ +/** + * AWS Lambda@Edge: C2PA Image Provenance + * + * Injects a C2PA-Manifest-URL header into image responses by looking up + * the manifest via the WordPress REST API. + * + * Environment variable: WORDPRESS_REST_URL + * e.g. https://your-site.com/wp-json + * + * For CDN-transform survival (pHash matching), use Encypher free API: + * https://encypherai.com + */ + +import https from 'https'; + +const WORDPRESS_REST_URL = process.env.WORDPRESS_REST_URL || ''; + +function httpsGet(url) { + return new Promise((resolve, reject) => { + https.get(url, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + resolve({ status: res.statusCode, body: JSON.parse(data) }); + } catch (e) { + resolve({ status: res.statusCode, body: null }); + } + }); + }).on('error', reject); + }); +} + +export const handler = async (event) => { + const response = event.Records[0].cf.response; + const request = event.Records[0].cf.request; + + const contentType = (response.headers['content-type'] || [{ value: '' }])[0].value; + if (!contentType.startsWith('image/')) { + return response; + } + + // Canonical URL: strip query params. + const uri = request.uri; + const host = request.headers['host'][0].value; + const canonicalUrl = `https://${host}${uri}`; + + if (!WORDPRESS_REST_URL) { + return response; + } + + try { + const lookupUrl = `${WORDPRESS_REST_URL}/c2pa-provenance/v1/images/lookup?url=${encodeURIComponent(canonicalUrl)}`; + const result = await httpsGet(lookupUrl); + + if (result.status === 200 && result.body && result.body.manifest_url) { + response.headers['c2pa-manifest-url'] = [{ key: 'C2PA-Manifest-URL', value: result.body.manifest_url }]; + } + } catch (e) { + // Lookup failed — return original response. + } + + return response; +}; diff --git a/docs/experiments/content-provenance-developer.md b/docs/experiments/content-provenance-developer.md new file mode 100644 index 00000000..89923955 --- /dev/null +++ b/docs/experiments/content-provenance-developer.md @@ -0,0 +1,134 @@ +# Content Provenance — Developer Guide + +## Abilities API + +Two abilities are registered: `c2pa/sign` and `c2pa/verify`. + +### c2pa/sign + +Signs text content with C2PA provenance using the configured signing tier. + +**Input:** +```json +{ + "text": "string (required) — plain text to sign", + "action": "string (optional) — 'c2pa.created' | 'c2pa.edited', default: 'c2pa.created'", + "metadata": "object (optional) — { title, url, author, post_id }" +} +``` + +**Output:** +```json +{ + "signed_text": "string — text with embedded Unicode provenance", + "manifest": "string — JSON manifest", + "signer_tier": "string — 'local' | 'connected' | 'byok'" +} +``` + +### c2pa/verify + +Verifies C2PA provenance embedded in text. No authentication required. + +**Input:** +```json +{ "text": "string (required) — text to verify" } +``` + +**Output:** +```json +{ + "verified": "bool", + "status": "string — 'verified' | 'tampered' | 'unsigned' | 'invalid'", + "manifest": "object|null — parsed manifest if present", + "error": "string|null — error description if any" +} +``` + +## Hooks + +### `ai_experiments_register_experiments` + +Register a custom signing backend or extend behaviour: + +```php +add_action( 'ai_experiments_register_experiments', function( $registry ) { + // Access the content provenance experiment. + $experiment = $registry->get_experiment( 'content-provenance' ); +} ); +``` + +### `ai_content_provenance_experiment_instance` + +Provides the Content_Provenance experiment instance to the C2PA_Sign ability: + +```php +add_filter( 'ai_content_provenance_experiment_instance', function( $experiment ) { + // Return a custom Content_Provenance instance if needed. + return $experiment; +} ); +``` + +### `ai_experiment_content-provenance_enabled` + +Filter the experiment's enabled state programmatically: + +```php +add_filter( 'ai_experiments_experiment_content-provenance_enabled', '__return_true' ); +``` + +## Signing Interface + +Implement `WordPress\AI\Experiments\Content_Provenance\Signing\Signing_Interface` to provide a custom signing backend: + +```php +use WordPress\AI\Experiments\Content_Provenance\Signing\Signing_Interface; + +class My_Custom_Signer implements Signing_Interface { + public function sign( string $content, array $claims ) { + // Your signing logic here. + // Return JSON manifest string or WP_Error. + } + + public function get_tier(): string { + return 'connected'; + } +} +``` + +## Unicode Embedding + +The `Unicode_Embedder` class handles low-level embedding: + +```php +use WordPress\AI\Experiments\Content_Provenance\Unicode_Embedder; + +// Embed a payload. +$signed_text = Unicode_Embedder::embed( $plain_text, $manifest_json ); + +// Extract embedded payload. +$manifest_json = Unicode_Embedder::extract( $signed_text ); // null if no embedding + +// Strip all embeddings. +$clean_text = Unicode_Embedder::strip( $signed_text ); +``` + +## REST Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `POST` | `/wp-json/c2pa-provenance/v1/verify` | Public | Verify text provenance | +| `GET` | `/wp-json/c2pa-provenance/v1/status` | Editor | Post signing status | +| `GET` | `/.well-known/c2pa` | Public | Discovery document (C2PA §6.4) | + +## Testing + +Run integration tests: +```bash +composer test -- --filter Content_Provenance +``` + +Or run a specific test: +```bash +composer test -- --filter test_unicode_embed_extract_roundtrip +``` diff --git a/docs/experiments/content-provenance.md b/docs/experiments/content-provenance.md new file mode 100644 index 00000000..5b7b96ec --- /dev/null +++ b/docs/experiments/content-provenance.md @@ -0,0 +1,139 @@ +# Content Provenance + +**Status:** Experiment +**Category:** Editor +**Requires:** WordPress 6.0+, PHP 7.4+ +**Version added:** 0.5.0 + +## Overview + +The Content Provenance experiment embeds cryptographic proof of origin into published content using the [C2PA 2.3 text authentication specification](https://spec.c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#embedding_manifests_into_unstructured_text) (Section A.7). When you publish or update a post, an invisible C2PA manifest is woven into the text using Unicode variation selectors. Anyone with the text — even if it was copy-pasted, scraped, or syndicated — can verify it came from your site and hasn't been modified. + +Zero editorial workflow change. Signing happens automatically at publish time. + +## Setup + +1. Go to **Settings → AI Experiments** +2. Enable **Content Provenance** +3. Choose your signing tier (see below) +4. Publish or update a post — it will be signed automatically + +## Signing Tiers + +### Local Signing (default, zero setup) + +A keypair is generated locally when you enable the experiment. No accounts, no network dependencies, no costs. + +**What works:** Anyone can verify your content hasn't been tampered with since publication. + +**Limitation:** The signer's identity is not on the C2PA Trust List. External validators (Adobe Content Credentials, etc.) can confirm content integrity but may show the signer as "unverified." This is analogous to a self-signed HTTPS certificate: the cryptography works, the organizational identity isn't independently confirmed. + +### Connected Signing (optional) + +Configure a C2PA-compliant signing service for full trust list verification. External validators will confirm both content integrity and your organizational identity. + +**Settings required:** +- **Signing service URL** — endpoint of a C2PA-compliant signing service +- **API key** — credential for the signing service + +Any C2PA-compliant signing service works. + +### BYOK — Bring Your Own Key (advanced) + +Use your own code signing certificate. Full control over your trust chain. + +**Settings required:** +- **Certificate path** — path to your PEM-format signing certificate + +## Badge States + +The Gutenberg sidebar shows a shield badge reflecting the current signing status: + +| Badge | Meaning | +|-------|---------| +| 🟢 Green shield (filled, checkmark) | Signed with verified organizational identity (connected/BYOK) | +| 🔵 Blue shield (outline) | Signed with local key — content integrity verifiable, identity unverified | +| 🟡 Yellow shield (warning) | Content modified since last signing — will re-sign on next publish | +| 🔴 Red shield (X) | Tamper detected — content does not match signed manifest | +| ⚪ Grey shield (empty) | Not signed | + +## Verification + +### In the editor + +Click **Verify** in the Content Provenance sidebar panel to check the current post content against its stored manifest. + +### Public REST endpoint + +``` +POST /wp-json/c2pa-provenance/v1/verify +Content-Type: application/json + +{ "text": "Content to verify (with embedded Unicode provenance)..." } +``` + +Response: +```json +{ + "verified": true, + "status": "verified", + "manifest": { ... }, + "signed_at": "2026-03-10T12:00:00Z", + "signer_tier": "local" +} +``` + +### Standards-based discovery + +The experiment registers a `/.well-known/c2pa` endpoint per C2PA 2.x §6.4. C2PA-aware tools and crawlers can discover provenance information for your site at: + +``` +GET /.well-known/c2pa +``` + +## WordPress Abilities API + +This experiment registers two abilities that other plugins can use: + +```php +// Sign content +$result = wp_do_ability( 'c2pa/sign', [ + 'text' => 'Content to sign', + 'action' => 'c2pa.created', // or 'c2pa.edited' + 'metadata' => [ 'title' => 'Post Title', 'url' => 'https://...' ], +] ); + +if ( ! is_wp_error( $result ) ) { + $signed_text = $result['signed_text']; // text with embedded provenance + $manifest = $result['manifest']; // JSON manifest +} + +// Verify content +$verification = wp_do_ability( 'c2pa/verify', [ + 'text' => $text_to_verify, +] ); + +// $verification['verified'] === true|false +// $verification['status'] === 'verified'|'tampered'|'unsigned'|'invalid' +``` + +## Provenance Chain + +When a post is updated, the new manifest includes a reference to the previous manifest as a C2PA ingredient (`c2pa.ingredient.v2`). This creates a verifiable edit history: each version of the post can be traced back to the original publication. + +## Post Meta + +The experiment stores the following post meta: + +| Key | Type | Description | +|-----|------|-------------| +| `_c2pa_manifest` | string | JSON manifest for the current version | +| `_c2pa_status` | string | `signed`, `error` | +| `_c2pa_signed_at` | string | ISO 8601 timestamp of last signing | +| `_c2pa_signer_tier` | string | `local`, `connected`, or `byok` | +| `_c2pa_content_hash` | string | SHA-256 hash of signed content | + +## Standards Reference + +- [C2PA 2.3 Specification §A.7](https://spec.c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#embedding_manifests_into_unstructured_text) — Text manifest embedding +- [C2PA 2.x §6.4](https://spec.c2pa.org/) — External manifest URI discovery diff --git a/includes/Abilities/Content_Provenance/C2PA_Sign.php b/includes/Abilities/Content_Provenance/C2PA_Sign.php new file mode 100644 index 00000000..ee209aa6 --- /dev/null +++ b/includes/Abilities/Content_Provenance/C2PA_Sign.php @@ -0,0 +1,218 @@ + '...', 'metadata' => [...] ] ) + * + * Returns the signed text (with embedded Unicode provenance) on success. + * + * @since 0.5.0 + */ +class C2PA_Sign extends Abstract_Ability { + + /** + * Constructor. + * + * @since 0.5.0 + */ + public function __construct() { + parent::__construct( + 'c2pa/sign', + array( + 'label' => __( 'C2PA: Sign Content', 'ai' ), + 'description' => __( 'Embed C2PA 2.3 cryptographic provenance into text content. Returns signed text with invisible Unicode watermark.', 'ai' ), + ) + ); + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @return array The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'text' => array( + 'type' => 'string', + 'sanitize_callback' => 'wp_kses_post', + 'description' => esc_html__( 'Plain text content to sign.', 'ai' ), + ), + 'action' => array( + 'type' => 'string', + 'enum' => array( 'c2pa.created', 'c2pa.edited' ), + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'C2PA action type.', 'ai' ), + ), + 'metadata' => array( + 'type' => 'object', + 'description' => esc_html__( 'Post metadata: title, url, author, post_id.', 'ai' ), + ), + ), + 'required' => array( 'text' ), + ); + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'signed_text' => array( + 'type' => 'string', + 'description' => esc_html__( 'Text with embedded C2PA provenance.', 'ai' ), + ), + 'manifest' => array( + 'type' => 'string', + 'description' => esc_html__( 'JSON manifest string.', 'ai' ), + ), + 'signer_tier' => array( + 'type' => 'string', + 'description' => esc_html__( 'Signing tier: local, connected, or byok.', 'ai' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @param mixed $input The input arguments to the ability. + * @return array{signed_text: string, manifest: string, signer_tier: string}|\WP_Error + */ + protected function execute_callback( $input ) { + $args = wp_parse_args( + is_array( $input ) ? $input : array(), + array( + 'text' => '', + 'action' => 'c2pa.created', + 'metadata' => array(), + ) + ); + + if ( empty( trim( $args['text'] ) ) ) { + return new WP_Error( 'c2pa_empty_text', esc_html__( 'Text is required to sign.', 'ai' ) ); + } + + // Use the Content_Provenance experiment's signer if available, otherwise fall back to local. + $experiment = $this->get_experiment(); + $signer = $experiment ? $experiment->get_public_signer() : $this->make_local_signer(); + + $result = C2PA_Manifest_Builder::build( + $args['text'], + $args['action'], + null, + is_array( $args['metadata'] ) ? $args['metadata'] : array(), + $signer + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $signed_text = Unicode_Embedder::embed( $args['text'], $result['manifest'] ); + + return array( + 'signed_text' => $signed_text, + 'manifest' => $result['manifest'], + 'signer_tier' => $signer->get_tier(), + ); + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @param mixed $input The input arguments to the ability. + * @return bool True if the user has permission. + */ + protected function permission_callback( $input ): bool { + return current_user_can( 'edit_posts' ); + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @return array The meta of the ability. + */ + protected function meta(): array { + return array( 'show_in_rest' => true ); + } + + /** + * Attempt to get the Content_Provenance experiment instance from the registry. + * + * @since 0.5.0 + * + * @return \WordPress\AI\Experiments\Content_Provenance\Content_Provenance|null + */ + private function get_experiment(): ?\WordPress\AI\Experiments\Content_Provenance\Content_Provenance { + // The experiment registry is not always accessible here; use a filter for loose coupling. + return apply_filters( 'ai_content_provenance_experiment_instance', null ); + } + + /** + * Build a fallback local signer using the stored keypair. + * + * @since 0.5.0 + * + * @return \WordPress\AI\Experiments\Content_Provenance\Signing\Local_Signer + */ + private function make_local_signer(): Local_Signer { + $keypair = get_option( '_c2pa_local_keypair', array() ); + if ( empty( $keypair['private_key'] ) ) { + $res = openssl_pkey_new( + array( + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + if ( false !== $res ) { + openssl_pkey_export( $res, $private_key ); + $details = openssl_pkey_get_details( $res ); + $public_key = is_array( $details ) ? ( $details['key'] ?? '' ) : ''; + $keypair = array( + 'private_key' => $private_key, + 'public_key' => $public_key, + ); + update_option( '_c2pa_local_keypair', $keypair ); + } + } + return new Local_Signer( $keypair ); + } +} diff --git a/includes/Abilities/Content_Provenance/C2PA_Verify.php b/includes/Abilities/Content_Provenance/C2PA_Verify.php new file mode 100644 index 00000000..da3fa68f --- /dev/null +++ b/includes/Abilities/Content_Provenance/C2PA_Verify.php @@ -0,0 +1,147 @@ + '...' ] ) + * + * @since 0.5.0 + */ +class C2PA_Verify extends Abstract_Ability { + + /** + * Constructor. + * + * @since 0.5.0 + */ + public function __construct() { + parent::__construct( + 'c2pa/verify', + array( + 'label' => __( 'C2PA: Verify Provenance', 'ai' ), + 'description' => __( 'Extract and verify C2PA 2.3 cryptographic provenance from signed text content. Returns verification status and manifest details.', 'ai' ), + ) + ); + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @return array The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'text' => array( + 'type' => 'string', + 'sanitize_callback' => 'wp_kses_post', + 'description' => esc_html__( 'Signed text content to verify.', 'ai' ), + ), + ), + 'required' => array( 'text' ), + ); + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'verified' => array( + 'type' => 'boolean', + 'description' => esc_html__( 'Whether the content signature is valid.', 'ai' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => esc_html__( 'Verification status message.', 'ai' ), + ), + 'manifest' => array( + 'type' => array( 'object', 'null' ), + 'description' => esc_html__( 'Decoded manifest object if found, or null.', 'ai' ), + ), + 'error' => array( + 'type' => array( 'string', 'null' ), + 'description' => esc_html__( 'Error message if verification failed, or null.', 'ai' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @param mixed $input The input arguments to the ability. + * @return array{verified: bool, status: string, manifest: array|null, error: string|null}|\WP_Error + */ + protected function execute_callback( $input ) { + $args = wp_parse_args( + is_array( $input ) ? $input : array(), + array( + 'text' => '', + ) + ); + + if ( empty( trim( $args['text'] ) ) ) { + return new WP_Error( 'c2pa_empty_text', esc_html__( 'Text is required to verify.', 'ai' ) ); + } + + return C2PA_Manifest_Builder::extract_and_verify( $args['text'] ); + } + + /** + * {@inheritDoc} + * + * Verification is public — no authentication required. + * + * @since 0.5.0 + * + * @param mixed $input The input arguments to the ability. + * @return bool Always true. + */ + protected function permission_callback( $input ): bool { + return true; + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @return array The meta of the ability. + */ + protected function meta(): array { + return array( 'show_in_rest' => true ); + } +} diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php index 20f49cea..6c7d11b7 100644 --- a/includes/Experiment_Loader.php +++ b/includes/Experiment_Loader.php @@ -111,6 +111,8 @@ private function get_default_experiments(): array { \WordPress\AI\Experiments\Review_Notes\Review_Notes::class, \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, + \WordPress\AI\Experiments\Content_Provenance\Content_Provenance::class, + \WordPress\AI\Experiments\Image_Provenance\Image_Provenance::class, ); /** diff --git a/includes/Experiments/Content_Provenance/C2PA_Manifest_Builder.php b/includes/Experiments/Content_Provenance/C2PA_Manifest_Builder.php new file mode 100644 index 00000000..edd4e5fb --- /dev/null +++ b/includes/Experiments/Content_Provenance/C2PA_Manifest_Builder.php @@ -0,0 +1,189 @@ + $metadata Post metadata: title, url, author, post_id. + * @param \WordPress\AI\Experiments\Content_Provenance\Signing\Signing_Interface $signer Signing backend to use. + * @return array{manifest: string, content_hash: string}|\WP_Error Signed manifest and hash, or error. + */ + public static function build( + string $content, + string $action, + ?string $previous_manifest, + array $metadata, + Signing_Interface $signer + ) { + $content_hash = hash( 'sha256', $content ); + + $claims = array( + 'title' => $metadata['title'] ?? '', + 'author' => $metadata['author'] ?? get_bloginfo( 'name' ), + 'url' => $metadata['url'] ?? '', + 'post_id' => $metadata['post_id'] ?? 0, + 'generated_at' => gmdate( 'c' ), + 'generator' => 'WordPress/AI Content Provenance Experiment', + 'assertions' => array( + 'c2pa.actions.v1' => array( + 'action' => $action, + 'digitalSourceType' => 'humanEdited', + ), + 'c2pa.hash.data.v1' => array( + 'algorithm' => 'sha256', + 'hash' => $content_hash, + ), + 'c2pa.soft_binding.v1' => array( + 'alg' => 'vs16', + 'document_length' => mb_strlen( $content ), + ), + ), + ); + + // Add ingredient reference for edited content. + if ( 'c2pa.edited' === $action && null !== $previous_manifest ) { + $claims['assertions']['c2pa.ingredient.v2'] = array( + 'relationship' => 'parentOf', + 'dc:title' => $metadata['title'] ?? '', + 'thumbnail' => null, + 'manifest_data' => $previous_manifest, + ); + } + + $manifest_json = wp_json_encode( + array( + 'magic' => base64_encode( self::MAGIC ), + 'version' => self::VERSION, + 'claims' => $claims, + ) + ); + + if ( ! $manifest_json ) { + return new \WP_Error( + 'c2pa_manifest_encode_failed', + esc_html__( 'Failed to encode C2PA manifest.', 'ai' ) + ); + } + + $result = $signer->sign( $content, $claims ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'manifest' => $result, + 'content_hash' => $content_hash, + ); + } + + /** + * Extract and verify C2PA provenance from text. + * + * Extracts embedded variation-selector data, decodes the manifest JSON, + * and validates the SHA-256 content hash against the stripped plain text. + * Signature cryptographic verification is intentionally out of scope here + * and delegated to the relevant ability class. + * + * @since 0.5.0 + * + * @param string $text Text that may contain embedded Unicode provenance. + * @return array{verified: bool, status: string, manifest: array|null, error: string|null} + */ + public static function extract_and_verify( string $text ): array { + $embedded = Unicode_Embedder::extract( $text ); + + if ( null === $embedded ) { + return array( + 'verified' => false, + 'status' => 'unsigned', + 'manifest' => null, + 'error' => null, + ); + } + + $manifest = json_decode( $embedded, true ); + + if ( ! is_array( $manifest ) ) { + return array( + 'verified' => false, + 'status' => 'invalid', + 'manifest' => null, + 'error' => 'Could not parse manifest', + ); + } + + // Verify content hash against stripped plain text. + $plain_text = Unicode_Embedder::strip( $text ); + $content_hash = hash( 'sha256', $plain_text ); + $stored_hash = $manifest['claims']['assertions']['c2pa.hash.data.v1']['hash'] ?? null; + + if ( $stored_hash !== $content_hash ) { + return array( + 'verified' => false, + 'status' => 'tampered', + 'manifest' => $manifest, + 'error' => 'Content hash mismatch', + ); + } + + return array( + 'verified' => true, + 'status' => 'verified', + 'manifest' => $manifest, + 'error' => null, + ); + } +} diff --git a/includes/Experiments/Content_Provenance/Content_Provenance.php b/includes/Experiments/Content_Provenance/Content_Provenance.php new file mode 100644 index 00000000..de5aa84b --- /dev/null +++ b/includes/Experiments/Content_Provenance/Content_Provenance.php @@ -0,0 +1,928 @@ + 'content-provenance', + 'label' => __( 'Content Provenance', 'ai' ), + 'description' => __( 'Embeds cryptographic proof of origin into published content using the C2PA 2.3 text authentication specification. Proof survives copy-paste, scraping, and syndication.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * Registers all WordPress hooks for this experiment. + * + * Sets up signing hooks, REST routes, well-known endpoint, and editor assets. + * Also hooks into the experiment-enabled toggle so the local keypair is + * generated on first activation. + * + * @since 0.5.0 + */ + public function register(): void { + // Sign on first publication. + add_action( 'publish_post', array( $this, 'sign_on_publish' ), 20, 2 ); + + // Re-sign when content is updated. + add_action( 'post_updated', array( $this, 'sign_on_update' ), 20, 3 ); + + // Register c2pa/sign and c2pa/verify abilities. + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + + // Block editor sidebar panel. + add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) ); + + // REST endpoints for verification and status. + add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); + + // Rewrite rule for /.well-known/c2pa. + add_action( 'init', array( $this, 'add_well_known_rewrite' ) ); + + // Serve the well-known discovery document. + add_action( 'template_redirect', array( $this, 'handle_well_known_request' ) ); + + // Keypair generation on toggle. + add_action( + 'update_option_ai_experiment_content-provenance_enabled', + array( $this, 'on_toggle' ), + 10, + 2 + ); + } + + /** + * Registers experiment-specific settings with the WordPress Settings API. + * + * All options are namespaced via get_field_option_name() and grouped under + * the 'ai_experiments' settings group used by the experiments settings page. + * + * @since 0.5.0 + */ + public function register_settings(): void { + register_setting( + 'ai_experiments', + $this->get_field_option_name( 'signing_tier' ), + array( + 'sanitize_callback' => 'sanitize_text_field', + 'default' => 'local', + ) + ); + + register_setting( + 'ai_experiments', + $this->get_field_option_name( 'connected_service_url' ), + array( + 'sanitize_callback' => 'esc_url_raw', + 'default' => '', + ) + ); + + register_setting( + 'ai_experiments', + $this->get_field_option_name( 'connected_service_api_key' ), + array( + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ) + ); + + register_setting( + 'ai_experiments', + $this->get_field_option_name( 'byok_certificate' ), + array( + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ) + ); + + register_setting( + 'ai_experiments', + $this->get_field_option_name( 'auto_sign' ), + array( + 'sanitize_callback' => 'rest_sanitize_boolean', + 'default' => true, + ) + ); + + register_setting( + 'ai_experiments', + $this->get_field_option_name( 'show_badge' ), + array( + 'sanitize_callback' => 'rest_sanitize_boolean', + 'default' => true, + ) + ); + + register_setting( + 'ai_experiments', + $this->get_field_option_name( 'badge_position' ), + array( + 'sanitize_callback' => 'sanitize_text_field', + 'default' => 'below', + ) + ); + } + + /** + * Renders the experiment settings fields inside the experiment card. + * + * Outputs signing-tier selection, conditional service configuration inputs, + * badge display controls, and a short explanation of trust tiers per PRD §4.1. + * + * @since 0.5.0 + */ + public function render_settings_fields(): void { + $signing_tier_raw = $this->get_signing_option( 'signing_tier' ); + $signing_tier = $signing_tier_raw ? (string) $signing_tier_raw : 'local'; + $connected_service_url = (string) $this->get_signing_option( 'connected_service_url' ); + $connected_service_api_key = (string) $this->get_signing_option( 'connected_service_api_key' ); + $byok_certificate = (string) $this->get_signing_option( 'byok_certificate' ); + $auto_sign = (bool) $this->get_signing_option( 'auto_sign' ); + $show_badge = (bool) $this->get_signing_option( 'show_badge' ); + $badge_position_raw = (string) $this->get_signing_option( 'badge_position' ); + $badge_position = $badge_position_raw ? $badge_position_raw : 'below'; + + $tier_name_signing = $this->get_field_option_name( 'signing_tier' ); + $tier_name_service_url = $this->get_field_option_name( 'connected_service_url' ); + $tier_name_api_key = $this->get_field_option_name( 'connected_service_api_key' ); + $tier_name_byok_cert = $this->get_field_option_name( 'byok_certificate' ); + $tier_name_auto_sign = $this->get_field_option_name( 'auto_sign' ); + $tier_name_show_badge = $this->get_field_option_name( 'show_badge' ); + $tier_name_badge_pos = $this->get_field_option_name( 'badge_position' ); + ?> +
+ + + + +
+ +

+ +

+ + + + + + + +
+ +
> + +

+ +

+ + + + + + + + + + + +
+ +
> + +

+ +

+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+
+ get_signing_option( 'auto_sign' ) ) { + return; + } + + if ( wp_is_post_revision( $post_id ) ) { + return; + } + + if ( 'auto-draft' === $post->post_status ) { + return; + } + + $this->sign_post( $post_id, $post, 'c2pa.created' ); + } + + /** + * Re-signs a post when its content changes after initial publication. + * + * Hooked to 'post_updated' at priority 20. Skips non-published posts and + * updates that do not change the post content, to avoid churning signatures. + * + * @since 0.5.0 + * + * @param int $post_id The post ID. + * @param \WP_Post $post_after The post object after the update. + * @param \WP_Post $post_before The post object before the update. + */ + public function sign_on_update( int $post_id, \WP_Post $post_after, \WP_Post $post_before ): void { + if ( ! $this->get_signing_option( 'auto_sign' ) ) { + return; + } + + if ( 'publish' !== $post_after->post_status ) { + return; + } + + if ( $post_after->post_content === $post_before->post_content ) { + return; + } + + $this->sign_post( $post_id, $post_after, 'c2pa.edited', $post_before ); + } + + /** + * Builds, signs, embeds, and persists a C2PA manifest for a post. + * + * Strips HTML to obtain plain text, builds the C2PA claims structure, + * signs it via the configured signing tier, embeds the manifest using + * Unicode variation selectors, and stores the result back to the post. + * Failures are logged and stored in post meta — publication is never blocked. + * + * @since 0.5.0 + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object to sign. + * @param string $action C2PA action string: 'c2pa.created' or 'c2pa.edited'. + * @param \WP_Post|null $previous Optional previous post object for ingredient chain. + * @return bool True on success, false on failure. + */ + public function sign_post( int $post_id, \WP_Post $post, string $action, ?\WP_Post $previous = null ): bool { + $plain_text = wp_strip_all_tags( $post->post_content ); + + if ( empty( $plain_text ) ) { + return false; + } + + $previous_manifest = null; + if ( 'c2pa.edited' === $action ) { + $raw_manifest = get_post_meta( $post_id, '_c2pa_manifest', true ); + $previous_manifest = $raw_manifest ? (string) $raw_manifest : null; + } + + $signer = $this->get_signer(); + + $raw_permalink = get_permalink( $post_id ); + $metadata = array( + 'title' => $post->post_title, + 'url' => $raw_permalink ? (string) $raw_permalink : '', + 'author' => get_the_author_meta( 'display_name', (int) $post->post_author ), + 'post_id' => $post_id, + ); + + $result = C2PA_Manifest_Builder::build( + $plain_text, + $action, + $previous_manifest, + $metadata, + $signer + ); + + if ( is_wp_error( $result ) ) { + update_post_meta( $post_id, '_c2pa_status', 'error' ); + return false; + } + + $new_content = Unicode_Embedder::embed( $post->post_content, $result['manifest'] ); + + // Temporarily remove own hooks to avoid recursive triggering. + remove_action( 'publish_post', array( $this, 'sign_on_publish' ), 20 ); + remove_action( 'post_updated', array( $this, 'sign_on_update' ), 20 ); + + $update_result = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => $new_content, + ), + true + ); + + // Restore hooks. + add_action( 'publish_post', array( $this, 'sign_on_publish' ), 20, 2 ); + add_action( 'post_updated', array( $this, 'sign_on_update' ), 20, 3 ); + + if ( is_wp_error( $update_result ) ) { + update_post_meta( $post_id, '_c2pa_status', 'error' ); + return false; + } + + update_post_meta( $post_id, '_c2pa_manifest', $result['manifest'] ); + update_post_meta( $post_id, '_c2pa_status', 'signed' ); + update_post_meta( $post_id, '_c2pa_signed_at', gmdate( 'c' ) ); + update_post_meta( $post_id, '_c2pa_signer_tier', $signer->get_tier() ); + + return true; + } + + /** + * Registers the c2pa/sign and c2pa/verify abilities. + * + * Hooked to 'wp_abilities_api_init'. + * + * @since 0.5.0 + */ + public function register_abilities(): void { + wp_register_ability( + 'c2pa/sign', + array( + 'label' => __( 'C2PA: Sign Content', 'ai' ), + 'description' => __( 'Embed C2PA provenance into text content.', 'ai' ), + 'ability_class' => \WordPress\AI\Abilities\Content_Provenance\C2PA_Sign::class, + ) + ); + + wp_register_ability( + 'c2pa/verify', + array( + 'label' => __( 'C2PA: Verify Provenance', 'ai' ), + 'description' => __( 'Verify C2PA provenance in text content.', 'ai' ), + 'ability_class' => \WordPress\AI\Abilities\Content_Provenance\C2PA_Verify::class, + ) + ); + } + + /** + * Registers REST API endpoints for verification and signing status. + * + * Hooked to 'rest_api_init'. The /verify route is publicly accessible so + * third-party tools can verify provenance without authentication. The + * /status route requires edit_post capability for the requested post. + * + * @since 0.5.0 + */ + public function register_rest_routes(): void { + register_rest_route( + 'c2pa-provenance/v1', + '/verify', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'rest_verify_callback' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'text' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'wp_kses_post', + ), + ), + ) + ); + + register_rest_route( + 'c2pa-provenance/v1', + '/status', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'rest_status_callback' ), + 'permission_callback' => static function ( \WP_REST_Request $request ) { + return current_user_can( 'edit_post', (int) $request->get_param( 'post_id' ) ); + }, + 'args' => array( + 'post_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + } + + /** + * REST callback: verify C2PA provenance in submitted text. + * + * Extracts and validates the embedded manifest, returning a structured + * response that includes verification status, the parsed manifest, and + * any error detail. + * + * @since 0.5.0 + * + * @param \WP_REST_Request $request The REST request object. + * @return \WP_REST_Response + */ + public function rest_verify_callback( \WP_REST_Request $request ): \WP_REST_Response { + $text = (string) $request->get_param( 'text' ); + $result = C2PA_Manifest_Builder::extract_and_verify( $text ); + + $manifest = $result['manifest']; + $signed_at = null; + $signer_tier = null; + + if ( is_array( $manifest ) ) { + $signed_at = $manifest['signed_at'] ?? null; + $signer_tier = $manifest['signer'] ?? null; + } + + return new \WP_REST_Response( + array( + 'verified' => $result['verified'], + 'status' => $result['status'], + 'manifest' => $manifest, + 'signed_at' => $signed_at, + 'signer_tier' => $signer_tier, + ), + 200 + ); + } + + /** + * REST callback: return the signing status for a specific post. + * + * Reads post meta written by sign_post() and returns a summarised status + * payload for use in the block editor sidebar panel. + * + * @since 0.5.0 + * + * @param \WP_REST_Request $request The REST request object. + * @return \WP_REST_Response + */ + public function rest_status_callback( \WP_REST_Request $request ): \WP_REST_Response { + $post_id = (int) $request->get_param( 'post_id' ); + + $raw_status = get_post_meta( $post_id, '_c2pa_status', true ); + $status = $raw_status ? (string) $raw_status : 'unsigned'; + $raw_signed = get_post_meta( $post_id, '_c2pa_signed_at', true ); + $signed_at = $raw_signed ? (string) $raw_signed : null; + $raw_tier = get_post_meta( $post_id, '_c2pa_signer_tier', true ); + $tier = $raw_tier ? (string) $raw_tier : null; + $raw_mfst = get_post_meta( $post_id, '_c2pa_manifest', true ); + $manifest = $raw_mfst ? (string) $raw_mfst : null; + + // Provide a truncated preview rather than the full manifest. + $manifest_preview = null; + if ( $manifest ) { + $decoded = json_decode( $manifest, true ); + if ( is_array( $decoded ) ) { + $manifest_preview = array( + 'magic' => $decoded['magic'] ?? null, + 'version' => $decoded['version'] ?? null, + 'signer' => $decoded['signer'] ?? null, + ); + } + } + + return new \WP_REST_Response( + array( + 'status' => $status, + 'signed_at' => $signed_at, + 'signer_tier' => $tier, + 'manifest_preview' => $manifest_preview, + ), + 200 + ); + } + + /** + * Enqueues block editor assets for the provenance sidebar panel. + * + * Only loads on the post edit and new-post screens. Passes runtime + * configuration to the JS bundle via wp_localize_script. + * + * @since 0.5.0 + */ + public function enqueue_assets(): void { + $screen = get_current_screen(); + + if ( ! $screen ) { + return; + } + + $hook_suffix = $screen->base; + + if ( 'post' !== $hook_suffix && 'post-new' !== $hook_suffix ) { + return; + } + + Asset_Loader::enqueue_script( 'content_provenance', 'experiments/content-provenance' ); + Asset_Loader::localize_script( + 'content_provenance', + 'ContentProvenanceData', + array( + 'enabled' => $this->is_enabled(), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'restUrl' => rest_url( 'c2pa-provenance/v1' ), + 'signerTier' => ( $this->get_signing_option( 'signing_tier' ) ? (string) $this->get_signing_option( 'signing_tier' ) : 'local' ), + ) + ); + } + + /** + * Registers the /.well-known/c2pa rewrite rule. + * + * Adds a custom rewrite rule that maps the well-known URI to a custom + * query var so handle_well_known_request() can intercept and serve it. + * + * @since 0.5.0 + */ + public function add_well_known_rewrite(): void { + add_rewrite_rule( + '^\.well-known/c2pa/?$', + 'index.php?c2pa_well_known=1', + 'top' + ); + + add_filter( + 'query_vars', + static function ( array $vars ): array { + $vars[] = 'c2pa_well_known'; + return $vars; + } + ); + } + + /** + * Serves the /.well-known/c2pa discovery document when requested. + * + * Outputs a JSON manifest discovery document that identifies this site as + * a C2PA-capable content origin and provides the verification endpoint URL. + * + * @since 0.5.0 + */ + public function handle_well_known_request(): void { + if ( ! get_query_var( 'c2pa_well_known' ) ) { + return; + } + + $document = array( + '@context' => 'https://c2pa.org/well-known/v1', + 'verify_url' => rest_url( 'c2pa-provenance/v1/verify' ), + 'site_url' => home_url(), + 'site_name' => get_bloginfo( 'name' ), + 'generator' => 'WordPress/AI Content Provenance Experiment', + 'supported_tiers' => array( 'local', 'connected', 'byok' ), + 'active_tier' => ( $this->get_signing_option( 'signing_tier' ) ? (string) $this->get_signing_option( 'signing_tier' ) : 'local' ), + ); + + header( 'Content-Type: application/json; charset=utf-8' ); + header( 'Cache-Control: public, max-age=3600' ); + + echo wp_json_encode( $document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); + + exit; + } + + /** + * Handles the experiment enable/disable toggle. + * + * Generates the local keypair on first activation so it is available + * immediately when the first post is published. + * + * @since 0.5.0 + * + * @param mixed $old_value The old option value. + * @param mixed $new_value The new option value. + */ + public function on_toggle( $old_value, $new_value ): void { + if ( '1' !== $new_value ) { + return; + } + // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison + $this->ensure_local_keypair(); + } + + /** + * Generates and persists the local keypair if one does not already exist. + * + * Stores the keypair as a site option so it persists across requests. + * Uses 2048-bit RSA which balances key size with broad PHP environment + * compatibility. Called once on experiment activation. + * + * @since 0.5.0 + */ + public function ensure_local_keypair(): void { + $existing = get_option( '_c2pa_local_keypair' ); + + if ( is_array( $existing ) && ! empty( $existing['private_key'] ) ) { + return; + } + + $keypair = $this->generate_keypair(); + + if ( is_wp_error( $keypair ) ) { + return; + } + + update_option( '_c2pa_local_keypair', $keypair, false ); + } + + /** + * Returns the Signing_Interface implementation for the configured tier. + * + * Reads the signing_tier option and instantiates the appropriate backend. + * Defaults to Local_Signer when no tier is set. + * + * @since 0.5.0 + * + * @return \WordPress\AI\Experiments\Content_Provenance\Signing\Signing_Interface + */ + /** + * Returns the configured signer for external callers (e.g. the c2pa/sign Ability). + * + * @since 0.5.0 + * + * @return \WordPress\AI\Experiments\Content_Provenance\Signing\Signing_Interface + */ + public function get_public_signer(): Signing_Interface { + return $this->get_signer(); + } + + private function get_signer(): Signing_Interface { + $raw_tier = $this->get_signing_option( 'signing_tier' ); + $tier = $raw_tier ? (string) $raw_tier : 'local'; + + if ( 'connected' === $tier ) { + return new Connected_Signer( + (string) $this->get_signing_option( 'connected_service_url' ), + (string) $this->get_signing_option( 'connected_service_api_key' ) + ); + } + + if ( 'byok' === $tier ) { + return new BYOK_Signer( + (string) $this->get_signing_option( 'byok_certificate' ) + ); + } + + return new Local_Signer( $this->get_local_keypair() ); + } + + /** + * Returns the value of an experiment setting option. + * + * Wraps get_option() with the namespaced option name produced by + * get_field_option_name() to reduce boilerplate at call sites. + * + * @since 0.5.0 + * + * @param string $name Base option name (e.g. 'signing_tier'). + * @return mixed Option value, or false if not set. + */ + private function get_signing_option( string $name ) { + return get_option( $this->get_field_option_name( $name ) ); + } + + /** + * Retrieves or generates the local RSA keypair. + * + * Reads the persisted keypair from the '_c2pa_local_keypair' site option. + * If none exists (e.g. the option was deleted after activation), generates + * a new one on the fly and persists it. + * + * @since 0.5.0 + * + * @return array{private_key: string, public_key: string} + */ + private function get_local_keypair(): array { + $stored = get_option( '_c2pa_local_keypair' ); + + if ( is_array( $stored ) && ! empty( $stored['private_key'] ) ) { + /** @var array{private_key: string, public_key: string} $stored */ + return $stored; + } + + $keypair = $this->generate_keypair(); + + if ( is_wp_error( $keypair ) ) { + // Return a placeholder — signing will fail gracefully downstream. + return array( + 'private_key' => '', + 'public_key' => '', + ); + } + + update_option( '_c2pa_local_keypair', $keypair, false ); + + return $keypair; + } + + /** + * Generates a fresh RSA-2048 keypair using the PHP OpenSSL extension. + * + * @since 0.5.0 + * + * @return array{private_key: string, public_key: string}|\WP_Error Keypair array or WP_Error on failure. + */ + private function generate_keypair() { + $resource = openssl_pkey_new( + array( + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + + if ( false === $resource ) { + return new \WP_Error( + 'c2pa_keypair_gen_failed', + esc_html__( 'Failed to generate RSA keypair via OpenSSL. Ensure the OpenSSL PHP extension is available.', 'ai' ) + ); + } + + $private_key_pem = ''; + openssl_pkey_export( $resource, $private_key_pem ); + + $key_details = openssl_pkey_get_details( $resource ); + $public_key = is_array( $key_details ) ? ( $key_details['key'] ?? '' ) : ''; + + if ( empty( $private_key_pem ) || empty( $public_key ) ) { + return new \WP_Error( + 'c2pa_keypair_export_failed', + esc_html__( 'Failed to export RSA keypair from OpenSSL.', 'ai' ) + ); + } + + return array( + 'private_key' => $private_key_pem, + 'public_key' => $public_key, + ); + } +} diff --git a/includes/Experiments/Content_Provenance/Signing/BYOK_Signer.php b/includes/Experiments/Content_Provenance/Signing/BYOK_Signer.php new file mode 100644 index 00000000..0048601d --- /dev/null +++ b/includes/Experiments/Content_Provenance/Signing/BYOK_Signer.php @@ -0,0 +1,139 @@ +cert_path = $cert_path; + } + + /** + * {@inheritDoc} + * + * Loads the publisher's private key from disk, builds a C2PA manifest + * structure, signs it with SHA-256, and returns the manifest JSON with + * the signature embedded as base64. + * + * @since 0.5.0 + * + * @param string $content Plain text content to sign. + * @param array $claims C2PA claims/assertions to embed. + * @return string|\WP_Error JSON manifest string or WP_Error on failure. + */ + public function sign( string $content, array $claims ) { + if ( empty( $this->cert_path ) ) { + return new \WP_Error( + 'c2pa_byok_no_cert', + esc_html__( 'BYOK certificate path is not configured.', 'ai' ) + ); + } + + if ( ! is_readable( $this->cert_path ) ) { + return new \WP_Error( + 'c2pa_byok_cert_unreadable', + esc_html__( 'BYOK certificate file is not readable. Check the path and permissions.', 'ai' ) + ); + } + + $private_key = openssl_pkey_get_private( 'file://' . $this->cert_path ); + + if ( false === $private_key ) { + return new \WP_Error( + 'c2pa_byok_key_load_failed', + esc_html__( 'Failed to load BYOK private key. Ensure the file is a valid PEM-encoded private key.', 'ai' ) + ); + } + + $key_details = openssl_pkey_get_details( $private_key ); + $public_key = is_array( $key_details ) ? ( $key_details['key'] ?? '' ) : ''; + + $manifest_data = array( + 'magic' => base64_encode( "\x43\x32\x50\x41\x54\x58\x54\x00" ), + 'version' => 1, + 'claims' => $claims, + 'signer' => 'byok', + 'signed_at' => gmdate( 'c' ), + 'public_key' => $public_key, + ); + + $manifest_json = wp_json_encode( $manifest_data ); + + if ( false === $manifest_json ) { + return new \WP_Error( + 'c2pa_manifest_encode_failed', + esc_html__( 'Failed to encode C2PA manifest for BYOK signing.', 'ai' ) + ); + } + + $signature = ''; + $signed = openssl_sign( $manifest_json, $signature, $private_key, OPENSSL_ALGO_SHA256 ); + + if ( ! $signed ) { + return new \WP_Error( + 'c2pa_byok_sign_failed', + esc_html__( 'OpenSSL BYOK signing failed for C2PA manifest.', 'ai' ) + ); + } + + $manifest_data['signature'] = base64_encode( $signature ); + + $signed_manifest = wp_json_encode( $manifest_data ); + + if ( false === $signed_manifest ) { + return new \WP_Error( + 'c2pa_manifest_encode_failed', + esc_html__( 'Failed to encode signed BYOK C2PA manifest.', 'ai' ) + ); + } + + return $signed_manifest; + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @return string Always 'byok'. + */ + public function get_tier(): string { + return 'byok'; + } +} diff --git a/includes/Experiments/Content_Provenance/Signing/Connected_Signer.php b/includes/Experiments/Content_Provenance/Signing/Connected_Signer.php new file mode 100644 index 00000000..b62241cb --- /dev/null +++ b/includes/Experiments/Content_Provenance/Signing/Connected_Signer.php @@ -0,0 +1,149 @@ +service_url = $service_url; + $this->api_key = $api_key; + } + + /** + * {@inheritDoc} + * + * POSTs content and claims to the configured signing service and returns + * the manifest JSON produced by the service. + * + * @since 0.5.0 + * + * @param string $content Plain text content to sign. + * @param array $claims C2PA claims/assertions to embed. + * @return string|\WP_Error JSON manifest string or WP_Error on failure. + */ + public function sign( string $content, array $claims ) { + if ( empty( $this->service_url ) ) { + return new \WP_Error( + 'c2pa_connected_no_url', + esc_html__( 'Connected signing service URL is not configured.', 'ai' ) + ); + } + + $body = wp_json_encode( + array( + 'content' => $content, + 'claims' => $claims, + ) + ); + + if ( false === $body ) { + return new \WP_Error( + 'c2pa_request_encode_failed', + esc_html__( 'Failed to encode signing request body.', 'ai' ) + ); + } + + $response = wp_remote_post( + $this->service_url, + array( + 'headers' => array( + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $this->api_key, + ), + 'body' => $body, + 'timeout' => 3, + ) + ); + + if ( is_wp_error( $response ) ) { + return new \WP_Error( + 'c2pa_connected_request_failed', + sprintf( + /* translators: %s: Error message from the signing service request. */ + esc_html__( 'Connected signing service request failed: %s', 'ai' ), + esc_html( $response->get_error_message() ) + ) + ); + } + + $status_code = wp_remote_retrieve_response_code( $response ); + + if ( 200 !== (int) $status_code ) { + return new \WP_Error( + 'c2pa_connected_bad_response', + sprintf( + /* translators: %d: HTTP status code returned by the signing service. */ + esc_html__( 'Connected signing service returned HTTP %d.', 'ai' ), + (int) $status_code + ) + ); + } + + $body_raw = wp_remote_retrieve_body( $response ); + $decoded = json_decode( $body_raw, true ); + + if ( ! is_array( $decoded ) || empty( $decoded['manifest'] ) ) { + return new \WP_Error( + 'c2pa_connected_invalid_response', + esc_html__( 'Connected signing service returned an invalid or empty manifest.', 'ai' ) + ); + } + + return is_string( $decoded['manifest'] ) ? $decoded['manifest'] : (string) wp_json_encode( $decoded['manifest'] ); + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @return string Always 'connected'. + */ + public function get_tier(): string { + return 'connected'; + } +} diff --git a/includes/Experiments/Content_Provenance/Signing/Local_Signer.php b/includes/Experiments/Content_Provenance/Signing/Local_Signer.php new file mode 100644 index 00000000..59a69de5 --- /dev/null +++ b/includes/Experiments/Content_Provenance/Signing/Local_Signer.php @@ -0,0 +1,120 @@ +keypair = $keypair; + } + + /** + * {@inheritDoc} + * + * Builds a C2PA manifest structure, signs it with SHA-256 using the local + * private key, and returns the manifest JSON with the signature embedded. + * + * @since 0.5.0 + * + * @param string $content Plain text content to sign. + * @param array $claims C2PA claims/assertions to embed. + * @return string|\WP_Error JSON manifest string or WP_Error on failure. + */ + public function sign( string $content, array $claims ) { + $manifest_data = array( + 'magic' => base64_encode( "\x43\x32\x50\x41\x54\x58\x54\x00" ), + 'version' => 1, + 'claims' => $claims, + 'signer' => 'local', + 'signed_at' => gmdate( 'c' ), + 'public_key' => $this->keypair['public_key'], + ); + + $manifest_json = wp_json_encode( $manifest_data ); + + if ( false === $manifest_json ) { + return new \WP_Error( + 'c2pa_manifest_encode_failed', + esc_html__( 'Failed to encode C2PA manifest for signing.', 'ai' ) + ); + } + + $private_key = openssl_pkey_get_private( $this->keypair['private_key'] ); + + if ( false === $private_key ) { + return new \WP_Error( + 'c2pa_key_load_failed', + esc_html__( 'Failed to load local private key for C2PA signing.', 'ai' ) + ); + } + + $signature = ''; + $signed = openssl_sign( $manifest_json, $signature, $private_key, OPENSSL_ALGO_SHA256 ); + + if ( ! $signed ) { + return new \WP_Error( + 'c2pa_sign_failed', + esc_html__( 'OpenSSL signing failed for C2PA manifest.', 'ai' ) + ); + } + + $manifest_data['signature'] = base64_encode( $signature ); + + $signed_manifest = wp_json_encode( $manifest_data ); + + if ( false === $signed_manifest ) { + return new \WP_Error( + 'c2pa_manifest_encode_failed', + esc_html__( 'Failed to encode signed C2PA manifest.', 'ai' ) + ); + } + + return $signed_manifest; + } + + /** + * {@inheritDoc} + * + * @since 0.5.0 + * + * @return string Always 'local'. + */ + public function get_tier(): string { + return 'local'; + } +} diff --git a/includes/Experiments/Content_Provenance/Signing/Signing_Interface.php b/includes/Experiments/Content_Provenance/Signing/Signing_Interface.php new file mode 100644 index 00000000..a66960c5 --- /dev/null +++ b/includes/Experiments/Content_Provenance/Signing/Signing_Interface.php @@ -0,0 +1,45 @@ + $claims C2PA claims/assertions to embed. + * @return string|\WP_Error JSON manifest string or WP_Error on failure. + */ + public function sign( string $content, array $claims ); + + /** + * Returns the trust tier label for this signer. + * + * @since 0.5.0 + * + * @return string 'local' | 'connected' | 'byok' + */ + public function get_tier(): string; +} diff --git a/includes/Experiments/Content_Provenance/Unicode_Embedder.php b/includes/Experiments/Content_Provenance/Unicode_Embedder.php new file mode 100644 index 00000000..f1e002e3 --- /dev/null +++ b/includes/Experiments/Content_Provenance/Unicode_Embedder.php @@ -0,0 +1,229 @@ +> 24 ) & 0xFF; + $header_bytes[] = ( $manifest_len >> 16 ) & 0xFF; + $header_bytes[] = ( $manifest_len >> 8 ) & 0xFF; + $header_bytes[] = $manifest_len & 0xFF; + + // Encode header + manifest bytes as variation selectors, prefixed with U+FEFF. + $wrapper = self::PREFIX; + + foreach ( array_merge( $header_bytes, $manifest_bytes ) as $byte ) { + if ( $byte < 16 ) { + // U+FE00–U+FE0F (VS1–VS16): 3-byte sequence EF B8 80+n. + $wrapper .= "\xEF\xB8" . chr( 0x80 + $byte ); + } else { + // U+E0100–U+E01EF (VS17–VS256): 4-byte sequence F3 A0 {84-87} {80-BF}. + // The 240 code points split into 4 groups of 64; the 3rd byte cycles + // through 0x84–0x87 and the 4th byte cycles through 0x80–0xBF. + $n = $byte - 16; + $wrapper .= "\xF3\xA0" . chr( 0x84 + intdiv( $n, 64 ) ) . chr( 0x80 + ( $n % 64 ) ); + } + } + + // APPEND wrapper to text per C2PA 2.3 §A.7. + return $text . $wrapper; + } + + /** + * Extract embedded JSON from text. + * + * Scans for the U+FEFF marker anywhere in the string, decodes the variation-selector + * region, validates the C2PATextManifestWrapper binary header (magic + version + + * length), and returns the manifest payload bytes. + * + * Returns null if no valid wrapper is detected or if the header is invalid. + * + * @since 0.5.0 + * + * @param string $text Text potentially containing an embedded wrapper. + * @return string|null Extracted JSON string, or null if none found. + */ + public static function extract( string $text ): ?string { + // Search for the U+FEFF marker anywhere in the string. + $pos = strpos( $text, self::PREFIX ); + if ( false === $pos ) { + return null; + } + + // Decode variation-selector bytes starting after the U+FEFF marker. + $bytes = array(); + $i = $pos + strlen( self::PREFIX ); + $len = strlen( $text ); + + while ( $i < $len ) { + $b0 = ord( $text[ $i ] ); + + // U+FE00–U+FE0F: 3-byte sequence EF B8 80–8F. + if ( 0xEF === $b0 && isset( $text[ $i + 1 ], $text[ $i + 2 ] ) ) { + $b1 = ord( $text[ $i + 1 ] ); + $b2 = ord( $text[ $i + 2 ] ); + if ( 0xB8 === $b1 && $b2 >= 0x80 && $b2 <= 0x8F ) { + $bytes[] = $b2 - 0x80; + $i += 3; + continue; + } + } + + // U+E0100–U+E01EF: 4-byte sequence F3 A0 {84-87} {80-BF}. + if ( 0xF3 === $b0 && isset( $text[ $i + 1 ], $text[ $i + 2 ], $text[ $i + 3 ] ) ) { + $b1 = ord( $text[ $i + 1 ] ); + $b2 = ord( $text[ $i + 2 ] ); + $b3 = ord( $text[ $i + 3 ] ); + if ( 0xA0 === $b1 && $b2 >= 0x84 && $b2 <= 0x87 && $b3 >= 0x80 && $b3 <= 0xBF ) { + $bytes[] = ( ( $b2 - 0x84 ) * 64 ) + $b3 - 0x80 + 16; + $i += 4; + continue; + } + } + + // Not a variation selector — end of encoded region. + break; + } + + // Need at least HEADER_SIZE bytes to validate the wrapper. + if ( count( $bytes ) < self::HEADER_SIZE ) { + return null; + } + + // Validate magic bytes (first 8 bytes = "C2PATXT\0"). + $unpacked_magic = unpack( 'C*', self::WRAPPER_MAGIC ); + $magic = array_values( $unpacked_magic ? $unpacked_magic : array() ); + + for ( $k = 0; $k < 8; $k++ ) { + if ( $bytes[ $k ] !== $magic[ $k ] ) { + return null; + } + } + + // Validate version byte. + if ( self::WRAPPER_VERSION !== $bytes[8] ) { + return null; + } + + // Read 4-byte big-endian manifest length. + $manifest_len = ( $bytes[9] << 24 ) | ( $bytes[10] << 16 ) | ( $bytes[11] << 8 ) | $bytes[12]; + + if ( 0 === $manifest_len || count( $bytes ) < self::HEADER_SIZE + $manifest_len ) { + return null; + } + + $manifest_bytes = array_slice( $bytes, self::HEADER_SIZE, $manifest_len ); + + return pack( 'C*', ...$manifest_bytes ); + } + + /** + * Strip embedded variation selectors from text, returning clean plain text. + * + * Removes U+FEFF and all variation-selector byte sequences (VS1–VS256) so that + * the remaining string equals the original human-readable content. Safe to call + * on text that carries no embedding. + * + * @since 0.5.0 + * + * @param string $text Text with possible embedded wrapper. + * @return string Clean text without the C2PA wrapper. + */ + public static function strip( string $text ): string { + // Remove U+FEFF markers and VS1–VS16 / VS17–VS256 code points anywhere in the text. + $stripped = preg_replace( '/[\x{FEFF}\x{FE00}-\x{FE0F}\x{E0100}-\x{E01EF}]/u', '', $text ); + return null !== $stripped ? $stripped : $text; + } +} diff --git a/includes/Experiments/Content_Provenance/Verification_Badge.php b/includes/Experiments/Content_Provenance/Verification_Badge.php new file mode 100644 index 00000000..84f72ecc --- /dev/null +++ b/includes/Experiments/Content_Provenance/Verification_Badge.php @@ -0,0 +1,96 @@ + + + 'https://c2pa.org/schemas/c2pa-well-known/v1', + 'publisher' => get_bloginfo( 'name' ), + 'url' => home_url(), + 'signing' => array( + 'active' => true, + 'spec' => 'C2PA 2.3 Section A.7', + ), + 'verify' => array( + 'endpoint' => rest_url( 'c2pa-provenance/v1/verify' ), + ), + 'generated_at' => gmdate( 'c' ), + ); + + header( 'Content-Type: application/json' ); + header( 'Cache-Control: public, max-age=3600' ); + echo wp_json_encode( $document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); + exit; + } +} diff --git a/includes/Experiments/Image_Provenance/Image_Provenance.php b/includes/Experiments/Image_Provenance/Image_Provenance.php new file mode 100644 index 00000000..b3ff65c8 --- /dev/null +++ b/includes/Experiments/Image_Provenance/Image_Provenance.php @@ -0,0 +1,388 @@ + 'image-provenance', + 'label' => __( 'Image Provenance', 'ai' ), + 'description' => __( 'Signs image attachments with C2PA manifests on upload and injects provenance headers for CDN verification.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * Registers hooks for the experiment. + * + * @since 0.6.0 + */ + public function register(): void { + if ( $this->get_image_option( 'auto_sign_images' ) ) { + add_action( 'add_attachment', array( $this, 'sign_on_attachment_upload' ), 10, 1 ); + } + + add_action( 'send_headers', array( $this, 'inject_manifest_url_header' ) ); + add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); + } + + /** + * Registers experiment settings. + * + * @since 0.6.0 + */ + public function register_settings(): void { + register_setting( + 'ai_experiments', + $this->get_field_option_name( 'auto_sign_images' ), + array( + 'sanitize_callback' => 'rest_sanitize_boolean', + 'default' => true, + ) + ); + } + + /** + * Renders experiment settings fields. + * + * @since 0.6.0 + */ + public function render_settings_fields(): void { + $auto_sign = (bool) $this->get_image_option( 'auto_sign_images' ); + $name_auto = $this->get_field_option_name( 'auto_sign_images' ); + ?> +
+ + + + + + + + + +
+ 'image', + 'url' => $canonical_url, + 'attachment_id' => $attachment_id, + 'title' => get_the_title( $attachment_id ), + ); + + $signer = $this->get_signer(); + $result = C2PA_Manifest_Builder::build( $canonical_url, 'c2pa.created', null, $metadata, $signer ); + + if ( is_wp_error( $result ) ) { + update_post_meta( $attachment_id, '_c2pa_image_status', 'error' ); + return; + } + + $manifest_url = rest_url( "c2pa-provenance/v1/images/manifest/{$attachment_id}" ); + + update_post_meta( $attachment_id, '_c2pa_image_manifest', $result['manifest'] ); + update_post_meta( $attachment_id, '_c2pa_image_manifest_url', $manifest_url ); + update_post_meta( $attachment_id, '_c2pa_image_canonical_url', $canonical_url ); + update_post_meta( $attachment_id, '_c2pa_image_status', 'signed' ); + update_post_meta( $attachment_id, '_c2pa_image_signed_at', gmdate( 'c' ) ); + } + + /** + * Injects the C2PA-Manifest-URL header for singular pages with a featured image. + * + * @since 0.6.0 + */ + public function inject_manifest_url_header(): void { + if ( ! is_singular() ) { + return; + } + + $post_id = get_queried_object_id(); + + if ( ! $post_id ) { + return; + } + + $thumbnail_id = get_post_thumbnail_id( $post_id ); + + if ( ! $thumbnail_id ) { + return; + } + + $manifest_url = get_post_meta( $thumbnail_id, '_c2pa_image_manifest_url', true ); + + if ( ! $manifest_url ) { + return; + } + + header( 'C2PA-Manifest-URL: ' . esc_url_raw( $manifest_url ) ); + } + + /** + * Registers REST API routes for image manifest lookup and retrieval. + * + * @since 0.6.0 + */ + public function register_rest_routes(): void { + register_rest_route( + 'c2pa-provenance/v1', + '/images/lookup', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'rest_lookup_callback' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'url' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'esc_url_raw', + ), + ), + ) + ); + + register_rest_route( + 'c2pa-provenance/v1', + '/images/manifest/(?P\d+)', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'rest_manifest_callback' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'attachment_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + } + + /** + * REST callback: look up a manifest by canonical image URL. + * + * Strips common CDN transform query parameters before matching. + * + * @since 0.6.0 + * + * @param \WP_REST_Request $request The REST request. + * @return \WP_REST_Response + */ + public function rest_lookup_callback( \WP_REST_Request $request ) { + $url = (string) $request->get_param( 'url' ); + $parsed = wp_parse_url( $url ); + + if ( ! $parsed ) { + return new \WP_REST_Response( array( 'error' => 'invalid_url' ), 400 ); + } + + // Strip CDN transform query params — keep scheme + host + path only. + $canonical = ( $parsed['scheme'] ?? 'https' ) . '://' . ( $parsed['host'] ?? '' ) . ( $parsed['path'] ?? '' ); + + // Find attachment by stored canonical URL — single query, no loop. + // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_posts_get_posts + $results = get_posts( + array( + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'posts_per_page' => 1, + 'suppress_filters' => false, + 'fields' => 'ids', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_c2pa_image_canonical_url', + 'value' => $canonical, + ), + ), + ) + ); + + if ( empty( $results ) ) { + return new \WP_REST_Response( array( 'error' => 'not_found' ), 404 ); + } + + $attachment_id = (int) $results[0]; + $manifest_url = get_post_meta( $attachment_id, '_c2pa_image_manifest_url', true ); + + return new \WP_REST_Response( + array( + 'record_id' => (string) $attachment_id, + 'manifest_url' => $manifest_url, + ), + 200 + ); + } + + /** + * REST callback: return the stored manifest for an attachment. + * + * @since 0.6.0 + * + * @param \WP_REST_Request $request The REST request. + * @return \WP_REST_Response + */ + public function rest_manifest_callback( \WP_REST_Request $request ) { + $attachment_id = (int) $request->get_param( 'attachment_id' ); + $manifest = get_post_meta( $attachment_id, '_c2pa_image_manifest', true ); + + if ( ! $manifest ) { + return new \WP_REST_Response( array( 'error' => 'not_found' ), 404 ); + } + + return new \WP_REST_Response( json_decode( $manifest, true ), 200 ); + } + + /** + * Returns the Signing_Interface implementation for the configured tier. + * + * @since 0.6.0 + * + * @return \WordPress\AI\Experiments\Content_Provenance\Signing\Signing_Interface + */ + private function get_signer(): Signing_Interface { + return new Local_Signer( $this->get_local_keypair() ); + } + + /** + * Returns the value of an experiment setting option. + * + * @since 0.6.0 + * + * @param string $name Base option name. + * @return mixed Option value, or false if not set. + */ + private function get_image_option( string $name ) { + return get_option( $this->get_field_option_name( $name ) ); + } + + /** + * Retrieves or generates the local RSA keypair. + * + * @since 0.6.0 + * + * @return array{private_key: string, public_key: string} + */ + private function get_local_keypair(): array { + $stored = get_option( '_c2pa_local_keypair' ); + + if ( is_array( $stored ) && ! empty( $stored['private_key'] ) ) { + /** @var array{private_key: string, public_key: string} $stored */ + return $stored; + } + + $resource = openssl_pkey_new( + array( + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + + if ( false === $resource ) { + return array( + 'private_key' => '', + 'public_key' => '', + ); + } + + $private_key_pem = ''; + openssl_pkey_export( $resource, $private_key_pem ); + $key_details = openssl_pkey_get_details( $resource ); + $public_key = is_array( $key_details ) ? ( $key_details['key'] ?? '' ) : ''; + + if ( empty( $private_key_pem ) || empty( $public_key ) ) { + return array( + 'private_key' => '', + 'public_key' => '', + ); + } + + $keypair = array( + 'private_key' => $private_key_pem, + 'public_key' => $public_key, + ); + update_option( '_c2pa_local_keypair', $keypair, false ); + + return $keypair; + } +} diff --git a/src/experiments/content-provenance/index.js b/src/experiments/content-provenance/index.js new file mode 100644 index 00000000..3c9ec516 --- /dev/null +++ b/src/experiments/content-provenance/index.js @@ -0,0 +1,320 @@ +import { registerPlugin } from '@wordpress/plugins'; +import { PluginDocumentSettingPanel } from '@wordpress/edit-post'; +import { Button, Spinner, Notice } from '@wordpress/components'; +import { useState, useEffect, useCallback } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import apiFetch from '@wordpress/api-fetch'; +import { __ } from '@wordpress/i18n'; + +const data = window.ContentProvenanceData || {}; + +// ── Badge component ────────────────────────────────────────────────────────── + +const BADGE_CONFIG = { + verified: { + color: '#00a32a', + fill: '#d7f0de', + icon: '✓', + label: __( 'Signed — Identity Verified', 'ai' ), + }, + local_signed: { + color: '#2271b1', + fill: '#e8f3fb', + icon: '◈', + label: __( 'Signed — Content Integrity Verified', 'ai' ), + }, + modified: { + color: '#dba617', + fill: '#fcf9e8', + icon: '⚠', + label: __( 'Modified Since Signing', 'ai' ), + }, + tampered: { + color: '#cc1818', + fill: '#fce8e8', + icon: '✗', + label: __( 'Tamper Detected', 'ai' ), + }, + unsigned: { + color: '#8c8f94', + fill: '#f6f7f7', + icon: '○', + label: __( 'Not Signed', 'ai' ), + }, + loading: { + color: '#8c8f94', + fill: '#f6f7f7', + icon: '…', + label: __( 'Checking…', 'ai' ), + }, +}; + +const ShieldBadge = ( { status } ) => { + const cfg = BADGE_CONFIG[ status ] || BADGE_CONFIG.unsigned; + return ( +
+ + + { cfg.label } + +
+ ); +}; + +// ── Trust tier notice ──────────────────────────────────────────────────────── + +const TrustTierNotice = ( { tier, settingsUrl } ) => { + if ( 'local' !== tier ) { + return null; + } + return ( + + { __( + 'Signed with local key. Content integrity is verifiable but signer identity is not on the C2PA Trust List.', + 'ai' + ) } + { settingsUrl && ( + + { __( 'Connect a signing service →', 'ai' ) } + + ) } + + ); +}; + +// ── Main panel ─────────────────────────────────────────────────────────────── + +const ContentProvenancePanel = () => { + const postId = useSelect( + ( select ) => select( 'core/editor' ).getCurrentPostId(), + [] + ); + + const [ status, setStatus ] = useState( 'loading' ); + const [ signedAt, setSignedAt ] = useState( null ); + const [ signerTier, setSignerTier ] = useState( + data.signerTier || 'local' + ); + const [ verifyResult, setVerifyResult ] = useState( null ); + const [ isSigning, setIsSigning ] = useState( false ); + const [ isVerifying, setIsVerifying ] = useState( false ); + const [ error, setError ] = useState( '' ); + + const fetchStatus = useCallback( () => { + if ( ! postId ) { + return; + } + setStatus( 'loading' ); + apiFetch( { + url: `${ data.restUrl }/status?post_id=${ postId }`, + headers: { 'X-WP-Nonce': data.nonce }, + } ) + .then( ( res ) => { + setStatus( res.status || 'unsigned' ); + setSignedAt( res.signed_at || null ); + setSignerTier( res.signer_tier || data.signerTier || 'local' ); + setError( '' ); + } ) + .catch( () => { + setStatus( 'unsigned' ); + setError( '' ); + } ); + }, [ postId ] ); + + useEffect( () => { + fetchStatus(); + }, [ fetchStatus ] ); + + const handleSign = () => { + if ( isSigning ) { + return; + } + setIsSigning( true ); + setError( '' ); + apiFetch( { + path: `wp-abilities/v1/abilities/ai/content-provenance/run`, + method: 'POST', + headers: { 'X-WP-Nonce': data.nonce }, + data: { post_id: postId }, + } ) + .then( () => { + setIsSigning( false ); + fetchStatus(); + } ) + .catch( ( err ) => { + setIsSigning( false ); + setError( err?.message || __( 'Signing failed.', 'ai' ) ); + } ); + }; + + const handleVerify = () => { + if ( isVerifying ) { + return; + } + setIsVerifying( true ); + setVerifyResult( null ); + apiFetch( { + url: `${ data.restUrl }/status?post_id=${ postId }`, + headers: { 'X-WP-Nonce': data.nonce }, + } ) + .then( ( res ) => { + setVerifyResult( res ); + setIsVerifying( false ); + // Update badge based on verification. + if ( res.status ) { + setStatus( res.status ); + } + } ) + .catch( () => { + setIsVerifying( false ); + setError( __( 'Verification failed.', 'ai' ) ); + } ); + }; + + if ( ! data.enabled ) { + return ( + +

+ { __( + 'Enable the Content Provenance experiment in AI Experiments settings to add C2PA provenance to published content.', + 'ai' + ) } +

+
+ ); + } + + const chainInfo = signedAt ? ( +

+ { __( 'Last signed:', 'ai' ) } { signedAt } + { signerTier && <> · { signerTier } } +

+ ) : null; + + return ( + + + + { chainInfo } + { error && ( + + { error } + + ) } + { verifyResult && ( + setVerifyResult( null ) } + style={ { marginTop: '8px' } } + > + { verifyResult.verified + ? __( + 'Content integrity confirmed — no tampering detected.', + 'ai' + ) + : __( 'Verification result:', 'ai' ) + + ( verifyResult.status || 'unknown' ) } + + ) } +
+ + +
+
+ ); +}; + +registerPlugin( 'content-provenance', { + render: ContentProvenancePanel, + icon: 'shield', +} ); diff --git a/tests/Integration/Includes/Abilities/C2PA_Sign_Test.php b/tests/Integration/Includes/Abilities/C2PA_Sign_Test.php new file mode 100644 index 00000000..516f2558 --- /dev/null +++ b/tests/Integration/Includes/Abilities/C2PA_Sign_Test.php @@ -0,0 +1,279 @@ +assertInstanceOf( C2PA_Sign::class, $ability ); + } + + /** + * Test that execute_callback returns WP_Error for empty text. + * + * @since 0.5.0 + */ + public function test_sign_empty_text_returns_error(): void { + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $ability = new C2PA_Sign(); + $ref = new \ReflectionMethod( $ability, 'execute_callback' ); + $ref->setAccessible( true ); + $result = $ref->invoke( $ability, array( 'text' => '' ) ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'c2pa_empty_text', $result->get_error_code() ); + } + + /** + * Test that execute_callback signs valid text and returns expected keys. + * + * @since 0.5.0 + */ + public function test_sign_valid_text_returns_signed_text(): void { + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + // Pre-store a keypair so the fallback local signer works. + $res = openssl_pkey_new( + array( + 'private_key_bits' => 1024, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + openssl_pkey_export( $res, $private_key ); + $details = openssl_pkey_get_details( $res ); + update_option( + '_c2pa_local_keypair', + array( + 'private_key' => $private_key, + 'public_key' => $details['key'], + ) + ); + + $ability = new C2PA_Sign(); + $ref = new \ReflectionMethod( $ability, 'execute_callback' ); + $ref->setAccessible( true ); + $result = $ref->invoke( $ability, array( 'text' => 'Content to sign.' ) ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'signed_text', $result ); + $this->assertArrayHasKey( 'manifest', $result ); + $this->assertArrayHasKey( 'signer_tier', $result ); + $this->assertStringContainsString( 'Content to sign.', $result['signed_text'] ); + } + + /** + * Test that execute_callback handles non-array input gracefully. + * + * @since 0.5.0 + */ + public function test_sign_non_array_input_returns_error(): void { + $ability = new C2PA_Sign(); + $ref = new \ReflectionMethod( $ability, 'execute_callback' ); + $ref->setAccessible( true ); + $result = $ref->invoke( $ability, null ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * Test that permission_callback returns false for unauthenticated users. + * + * @since 0.5.0 + */ + public function test_permission_callback_returns_false_for_unauthenticated(): void { + wp_set_current_user( 0 ); + + $ability = new C2PA_Sign(); + $ref = new \ReflectionMethod( $ability, 'permission_callback' ); + $ref->setAccessible( true ); + + $this->assertFalse( $ref->invoke( $ability, array() ) ); + } + + /** + * Test that permission_callback returns true for a user with edit_posts. + * + * @since 0.5.0 + */ + public function test_permission_callback_returns_true_for_editor(): void { + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $ability = new C2PA_Sign(); + $ref = new \ReflectionMethod( $ability, 'permission_callback' ); + $ref->setAccessible( true ); + + $this->assertTrue( $ref->invoke( $ability, array() ) ); + } + + /** + * Test that input_schema requires 'text'. + * + * @since 0.5.0 + */ + public function test_input_schema_requires_text(): void { + $ability = new C2PA_Sign(); + $ref = new \ReflectionMethod( $ability, 'input_schema' ); + $ref->setAccessible( true ); + $schema = $ref->invoke( $ability ); + + $this->assertContains( 'text', $schema['required'] ); + } + + /** + * Test that output_schema includes expected keys. + * + * @since 0.5.0 + */ + public function test_output_schema_has_expected_properties(): void { + $ability = new C2PA_Sign(); + $ref = new \ReflectionMethod( $ability, 'output_schema' ); + $ref->setAccessible( true ); + $schema = $ref->invoke( $ability ); + + $this->assertArrayHasKey( 'signed_text', $schema['properties'] ); + $this->assertArrayHasKey( 'manifest', $schema['properties'] ); + $this->assertArrayHasKey( 'signer_tier', $schema['properties'] ); + } + + /** + * Test that execute_callback uses the experiment signer when provided via filter. + * + * Covers the '$experiment ? $experiment->get_public_signer() : ...' branch + * when get_experiment() returns a non-null Content_Provenance instance. + * + * @since 0.5.0 + */ + public function test_sign_uses_experiment_signer_when_filter_provides_experiment(): void { + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + // Pre-store a keypair so the experiment's local signer works. + $res = openssl_pkey_new( + array( + 'private_key_bits' => 1024, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + openssl_pkey_export( $res, $private_key ); + $details = openssl_pkey_get_details( $res ); + update_option( + '_c2pa_local_keypair', + array( + 'private_key' => $private_key, + 'public_key' => $details['key'], + ) + ); + + $experiment = new \WordPress\AI\Experiments\Content_Provenance\Content_Provenance(); + + add_filter( + 'ai_content_provenance_experiment_instance', + static function () use ( $experiment ) { + return $experiment; + } + ); + + $ability = new C2PA_Sign(); + $ref = new \ReflectionMethod( $ability, 'execute_callback' ); + $ref->setAccessible( true ); + $result = $ref->invoke( $ability, array( 'text' => 'Content via experiment signer.' ) ); + + remove_all_filters( 'ai_content_provenance_experiment_instance' ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'signed_text', $result ); + $this->assertArrayHasKey( 'manifest', $result ); + $this->assertSame( 'local', $result['signer_tier'] ); + } + + /** + * Test that execute_callback returns WP_Error when the signer itself fails. + * + * Covers the 'is_wp_error($result)' branch in execute_callback. + * + * @since 0.5.0 + */ + public function test_sign_returns_error_when_signer_fails(): void { + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + // Provide a mock experiment whose signer always fails. + $mock_signer = new class() implements \WordPress\AI\Experiments\Content_Provenance\Signing\Signing_Interface { + /** + * Always fail. + * + * @param string $content Content. + * @param array $claims Claims. + * @return \WP_Error + */ + public function sign( string $content, array $claims ) { + return new \WP_Error( 'signer_error', 'Signing failed.' ); + } + + /** + * Return tier. + * + * @return string + */ + public function get_tier(): string { + return 'mock'; + } + }; + + $mock_experiment = $this->createMock( \WordPress\AI\Experiments\Content_Provenance\Content_Provenance::class ); + $mock_experiment->method( 'get_public_signer' )->willReturn( $mock_signer ); + + add_filter( + 'ai_content_provenance_experiment_instance', + static function () use ( $mock_experiment ) { + return $mock_experiment; + } + ); + + $ability = new C2PA_Sign(); + $ref = new \ReflectionMethod( $ability, 'execute_callback' ); + $ref->setAccessible( true ); + $result = $ref->invoke( $ability, array( 'text' => 'Content to sign.' ) ); + + remove_all_filters( 'ai_content_provenance_experiment_instance' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'signer_error', $result->get_error_code() ); + } +} diff --git a/tests/Integration/Includes/Abilities/C2PA_Verify_Test.php b/tests/Integration/Includes/Abilities/C2PA_Verify_Test.php new file mode 100644 index 00000000..b64ec141 --- /dev/null +++ b/tests/Integration/Includes/Abilities/C2PA_Verify_Test.php @@ -0,0 +1,179 @@ +invoke_execute( $ability, array( 'text' => '' ) ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'c2pa_empty_text', $result->get_error_code() ); + } + + /** + * Test that execute_callback returns WP_Error for whitespace-only text. + * + * @since 0.5.0 + */ + public function test_execute_callback_returns_error_for_whitespace_text(): void { + $ability = new C2PA_Verify(); + $result = $this->invoke_execute( $ability, array( 'text' => ' ' ) ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * Test that execute_callback returns 'unsigned' status for plain text. + * + * @since 0.5.0 + */ + public function test_execute_callback_returns_unsigned_for_plain_text(): void { + $ability = new C2PA_Verify(); + $result = $this->invoke_execute( $ability, array( 'text' => 'Plain text, no provenance.' ) ); + + $this->assertIsArray( $result ); + $this->assertSame( 'unsigned', $result['status'] ); + $this->assertFalse( $result['verified'] ); + $this->assertNull( $result['manifest'] ); + } + + /** + * Test that execute_callback returns 'verified' for properly signed text. + * + * @since 0.5.0 + */ + public function test_execute_callback_returns_verified_for_signed_text(): void { + $keypair = $this->generate_test_keypair(); + $signer = new Local_Signer( $keypair ); + + $content = 'Signed content for verification.'; + $built = C2PA_Manifest_Builder::build( $content, 'c2pa.created', null, array(), $signer ); + $this->assertIsArray( $built ); + + $signed_text = Unicode_Embedder::embed( $content, $built['manifest'] ); + + $ability = new C2PA_Verify(); + $result = $this->invoke_execute( $ability, array( 'text' => $signed_text ) ); + + $this->assertIsArray( $result ); + $this->assertSame( 'verified', $result['status'] ); + $this->assertTrue( $result['verified'] ); + } + + /** + * Test that execute_callback handles non-array input gracefully. + * + * @since 0.5.0 + */ + public function test_execute_callback_handles_non_array_input(): void { + $ability = new C2PA_Verify(); + $result = $this->invoke_execute( $ability, null ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * Test that permission_callback always returns true (public access). + * + * @since 0.5.0 + */ + public function test_permission_callback_returns_true(): void { + $ability = new C2PA_Verify(); + $ref = new \ReflectionMethod( $ability, 'permission_callback' ); + $ref->setAccessible( true ); + + $this->assertTrue( $ref->invoke( $ability, array() ) ); + } + + /** + * Test that input_schema requires 'text' property. + * + * @since 0.5.0 + */ + public function test_input_schema_has_required_text(): void { + $ability = new C2PA_Verify(); + $ref = new \ReflectionMethod( $ability, 'input_schema' ); + $ref->setAccessible( true ); + $schema = $ref->invoke( $ability ); + + $this->assertContains( 'text', $schema['required'] ); + } + + /** + * Test that output_schema includes verified, status, manifest, error properties. + * + * @since 0.5.0 + */ + public function test_output_schema_has_expected_properties(): void { + $ability = new C2PA_Verify(); + $ref = new \ReflectionMethod( $ability, 'output_schema' ); + $ref->setAccessible( true ); + $schema = $ref->invoke( $ability ); + + $this->assertArrayHasKey( 'verified', $schema['properties'] ); + $this->assertArrayHasKey( 'status', $schema['properties'] ); + $this->assertArrayHasKey( 'manifest', $schema['properties'] ); + $this->assertArrayHasKey( 'error', $schema['properties'] ); + } + + /** + * Invoke execute_callback via reflection. + * + * @since 0.5.0 + * + * @param \WordPress\AI\Abilities\Content_Provenance\C2PA_Verify $ability The ability instance. + * @param mixed $input Input to pass. + * @return mixed + */ + private function invoke_execute( C2PA_Verify $ability, $input ) { + $ref = new \ReflectionMethod( $ability, 'execute_callback' ); + $ref->setAccessible( true ); + return $ref->invoke( $ability, $input ); + } + + /** + * Generate a test RSA keypair. + * + * @since 0.5.0 + * @return array{private_key: string, public_key: string} + */ + private function generate_test_keypair(): array { + $res = openssl_pkey_new( + array( + 'private_key_bits' => 1024, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + openssl_pkey_export( $res, $private_key ); + $details = openssl_pkey_get_details( $res ); + return array( + 'private_key' => $private_key, + 'public_key' => $details['key'], + ); + } +} diff --git a/tests/Integration/Includes/Experiments/Content_Provenance/Content_ProvenanceTest.php b/tests/Integration/Includes/Experiments/Content_Provenance/Content_ProvenanceTest.php new file mode 100644 index 00000000..c402747d --- /dev/null +++ b/tests/Integration/Includes/Experiments/Content_Provenance/Content_ProvenanceTest.php @@ -0,0 +1,847 @@ +register_default_experiments(); + + $experiment = $registry->get_experiment( 'content-provenance' ); + $this->assertInstanceOf( Content_Provenance::class, $experiment ); + } + + /** + * Tear down. + * + * @since 0.5.0 + */ + public function tearDown(): void { + delete_option( 'ai_experiments_enabled' ); + delete_option( 'ai_experiment_content-provenance_enabled' ); + delete_option( '_c2pa_local_keypair' ); + parent::tearDown(); + } + + /** + * Test experiment metadata. + * + * @since 0.5.0 + */ + public function test_experiment_metadata(): void { + $experiment = new Content_Provenance(); + + $this->assertSame( 'content-provenance', $experiment->get_id() ); + $this->assertSame( 'Content Provenance', $experiment->get_label() ); + $this->assertSame( Experiment_Category::EDITOR, $experiment->get_category() ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Test that the experiment registers in the loader. + * + * @since 0.5.0 + */ + public function test_experiment_is_in_default_experiments(): void { + $registry = new Experiment_Registry(); + $loader = new Experiment_Loader( $registry ); + $loader->register_default_experiments(); + + $experiment = $registry->get_experiment( 'content-provenance' ); + $this->assertInstanceOf( Content_Provenance::class, $experiment ); + } + + /** + * Test Unicode embedding roundtrip. + * + * @since 0.5.0 + */ + public function test_unicode_embed_extract_roundtrip(): void { + $original = 'Hello, WordPress.'; + $payload = '{"test":"provenance"}'; + + $embedded = Unicode_Embedder::embed( $original, $payload ); + $extracted = Unicode_Embedder::extract( $embedded ); + + $this->assertSame( $payload, $extracted, 'Extracted payload should match original.' ); + } + + /** + * Test Unicode strip returns clean text. + * + * @since 0.5.0 + */ + public function test_unicode_strip_returns_clean_text(): void { + $original = 'Clean text here.'; + $payload = '{"c2pa":"test"}'; + + $embedded = Unicode_Embedder::embed( $original, $payload ); + $stripped = Unicode_Embedder::strip( $embedded ); + + $this->assertStringContainsString( 'Clean text here.', $stripped ); + } + + /** + * Test that extract returns null for text without embedding. + * + * @since 0.5.0 + */ + public function test_extract_returns_null_for_unsigned_text(): void { + $result = Unicode_Embedder::extract( 'Plain text with no embedding.' ); + $this->assertNull( $result ); + } + + /** + * Test C2PA manifest builder builds a valid manifest. + * + * @since 0.5.0 + */ + public function test_manifest_builder_builds_valid_manifest(): void { + $keypair = $this->generate_test_keypair(); + $signer = new \WordPress\AI\Experiments\Content_Provenance\Signing\Local_Signer( $keypair ); + + $result = C2PA_Manifest_Builder::build( + 'Test content for signing.', + 'c2pa.created', + null, + array( + 'title' => 'Test Post', + 'post_id' => 1, + ), + $signer + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'manifest', $result ); + $this->assertArrayHasKey( 'content_hash', $result ); + $this->assertSame( hash( 'sha256', 'Test content for signing.' ), $result['content_hash'] ); + } + + /** + * Test that tampered content fails verification. + * + * @since 0.5.0 + */ + public function test_tampered_content_fails_verification(): void { + $keypair = $this->generate_test_keypair(); + $signer = new \WordPress\AI\Experiments\Content_Provenance\Signing\Local_Signer( $keypair ); + $original = 'Original content.'; + + $result = C2PA_Manifest_Builder::build( $original, 'c2pa.created', null, array(), $signer ); + $embedded = Unicode_Embedder::embed( $original, $result['manifest'] ); + + // Tamper: replace original content in the signed text. + $tampered = str_replace( $original, 'Tampered content.', $embedded ); + + $verification = C2PA_Manifest_Builder::extract_and_verify( $tampered ); + $this->assertSame( 'tampered', $verification['status'] ); + $this->assertFalse( $verification['verified'] ); + } + + /** + * Test that signing an empty string is handled gracefully. + * + * @since 0.5.0 + */ + public function test_sign_empty_content_returns_error(): void { + $keypair = $this->generate_test_keypair(); + $signer = new \WordPress\AI\Experiments\Content_Provenance\Signing\Local_Signer( $keypair ); + + // Empty content — builder should handle this gracefully. + $result = C2PA_Manifest_Builder::build( '', 'c2pa.created', null, array(), $signer ); + // Either returns an error or a valid result; should not throw. + $this->assertTrue( is_array( $result ) || is_wp_error( $result ) ); + } + + /** + * Test provenance chain: edited manifest references previous as ingredient. + * + * @since 0.5.0 + */ + public function test_edited_manifest_includes_ingredient_reference(): void { + $keypair = $this->generate_test_keypair(); + $signer = new \WordPress\AI\Experiments\Content_Provenance\Signing\Local_Signer( $keypair ); + + $first = C2PA_Manifest_Builder::build( 'Original.', 'c2pa.created', null, array(), $signer ); + $this->assertIsArray( $first ); + + $second = C2PA_Manifest_Builder::build( 'Edited.', 'c2pa.edited', $first['manifest'], array(), $signer ); + $this->assertIsArray( $second ); + + $manifest_data = json_decode( $second['manifest'], true ); + $this->assertArrayHasKey( + 'c2pa.ingredient.v2', + $manifest_data['claims']['assertions'] ?? array(), + 'Edited manifest should contain ingredient reference.' + ); + } + + /** + * Test auto-sign on publish hooks are registered. + * + * @since 0.5.0 + */ + public function test_register_hooks_on_publish_post(): void { + $experiment = new Content_Provenance(); + $experiment->register(); + + $this->assertGreaterThan( 0, has_action( 'publish_post', array( $experiment, 'sign_on_publish' ) ) ); + $this->assertGreaterThan( 0, has_action( 'post_updated', array( $experiment, 'sign_on_update' ) ) ); + } + + /** + * Test REST verification endpoint is registered. + * + * @since 0.5.0 + */ + public function test_rest_verify_route_registered(): void { + $experiment = new Content_Provenance(); + $experiment->register(); + do_action( 'rest_api_init' ); + + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/c2pa-provenance/v1/verify', $routes ); + } + + /** + * Test that sign_post() signs post content and stores meta. + * + * @since 0.5.0 + */ + public function test_sign_post_stores_meta(): void { + $keypair = $this->generate_test_keypair(); + update_option( '_c2pa_local_keypair', $keypair ); + + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Hello, this is test content for signing.', + ) + ); + $post = get_post( $post_id ); + + $experiment = new Content_Provenance(); + $result = $experiment->sign_post( $post_id, $post, 'c2pa.created' ); + + $this->assertTrue( $result ); + $this->assertSame( 'signed', get_post_meta( $post_id, '_c2pa_status', true ) ); + $this->assertNotEmpty( get_post_meta( $post_id, '_c2pa_manifest', true ) ); + $this->assertNotEmpty( get_post_meta( $post_id, '_c2pa_signed_at', true ) ); + $this->assertSame( 'local', get_post_meta( $post_id, '_c2pa_signer_tier', true ) ); + } + + /** + * Test that sign_post() returns false for a post with empty content. + * + * @since 0.5.0 + */ + public function test_sign_post_returns_false_for_empty_content(): void { + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_content' => '', + ) + ); + $post = get_post( $post_id ); + + $experiment = new Content_Provenance(); + $result = $experiment->sign_post( $post_id, $post, 'c2pa.created' ); + + $this->assertFalse( $result ); + } + + /** + * Test sign_on_publish skips auto-drafts. + * + * @since 0.5.0 + */ + public function test_sign_on_publish_skips_auto_draft(): void { + update_option( 'ai_experiment_content-provenance_auto_sign', true ); + + $post_id = $this->factory->post->create( + array( + 'post_status' => 'auto-draft', + 'post_content' => 'Some content.', + ) + ); + $post = get_post( $post_id ); + + $experiment = new Content_Provenance(); + $experiment->sign_on_publish( $post_id, $post ); + + $this->assertEmpty( get_post_meta( $post_id, '_c2pa_status', true ) ); + } + + /** + * Test sign_on_publish skips when auto_sign is disabled. + * + * @since 0.5.0 + */ + public function test_sign_on_publish_skips_when_auto_sign_disabled(): void { + // The option name follows the pattern: ai_experiment_{id}_field_{name}. + update_option( 'ai_experiment_content-provenance_field_auto_sign', false ); + + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Some content.', + ) + ); + $post = get_post( $post_id ); + + $experiment = new Content_Provenance(); + $experiment->sign_on_publish( $post_id, $post ); + + $this->assertEmpty( get_post_meta( $post_id, '_c2pa_status', true ) ); + + delete_option( 'ai_experiment_content-provenance_field_auto_sign' ); + } + + /** + * Test sign_on_update skips when content has not changed. + * + * @since 0.5.0 + */ + public function test_sign_on_update_skips_unchanged_content(): void { + update_option( 'ai_experiment_content-provenance_field_auto_sign', true ); + + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Identical content.', + ) + ); + $post_after = get_post( $post_id ); + $post_before = clone $post_after; + + $experiment = new Content_Provenance(); + $experiment->sign_on_update( $post_id, $post_after, $post_before ); + + $this->assertEmpty( get_post_meta( $post_id, '_c2pa_status', true ) ); + + delete_option( 'ai_experiment_content-provenance_field_auto_sign' ); + } + + /** + * Test sign_on_update skips non-published posts. + * + * @since 0.5.0 + */ + public function test_sign_on_update_skips_non_published(): void { + update_option( 'ai_experiment_content-provenance_field_auto_sign', true ); + + $post_id = $this->factory->post->create( + array( + 'post_status' => 'draft', + 'post_content' => 'Before.', + ) + ); + $post_before = get_post( $post_id ); + $post_after = clone $post_before; + + $post_after->post_content = 'After.'; + + $experiment = new Content_Provenance(); + $experiment->sign_on_update( $post_id, $post_after, $post_before ); + + $this->assertEmpty( get_post_meta( $post_id, '_c2pa_status', true ) ); + + delete_option( 'ai_experiment_content-provenance_field_auto_sign' ); + } + + /** + * Test ensure_local_keypair generates and stores a keypair when none exists. + * + * @since 0.5.0 + */ + public function test_ensure_local_keypair_generates_keypair(): void { + delete_option( '_c2pa_local_keypair' ); + + $experiment = new Content_Provenance(); + $experiment->ensure_local_keypair(); + + $stored = get_option( '_c2pa_local_keypair' ); + $this->assertIsArray( $stored ); + $this->assertNotEmpty( $stored['private_key'] ); + $this->assertNotEmpty( $stored['public_key'] ); + } + + /** + * Test ensure_local_keypair does not regenerate when one already exists. + * + * @since 0.5.0 + */ + public function test_ensure_local_keypair_does_not_regenerate_existing(): void { + $keypair = $this->generate_test_keypair(); + update_option( '_c2pa_local_keypair', $keypair ); + + $experiment = new Content_Provenance(); + $experiment->ensure_local_keypair(); + + $stored = get_option( '_c2pa_local_keypair' ); + $this->assertSame( $keypair['public_key'], $stored['public_key'] ); + } + + /** + * Test on_toggle generates keypair when new value is '1'. + * + * @since 0.5.0 + */ + public function test_on_toggle_generates_keypair_on_enable(): void { + delete_option( '_c2pa_local_keypair' ); + + $experiment = new Content_Provenance(); + $experiment->on_toggle( '0', '1' ); + + $stored = get_option( '_c2pa_local_keypair' ); + $this->assertIsArray( $stored ); + $this->assertNotEmpty( $stored['private_key'] ); + } + + /** + * Test on_toggle does nothing when new value is not '1'. + * + * @since 0.5.0 + */ + public function test_on_toggle_does_nothing_on_disable(): void { + delete_option( '_c2pa_local_keypair' ); + + $experiment = new Content_Provenance(); + $experiment->on_toggle( '1', '0' ); + + $stored = get_option( '_c2pa_local_keypair' ); + $this->assertFalse( $stored ); + } + + /** + * Test get_public_signer returns a Signing_Interface instance. + * + * @since 0.5.0 + */ + public function test_get_public_signer_returns_signing_interface(): void { + $keypair = $this->generate_test_keypair(); + update_option( '_c2pa_local_keypair', $keypair ); + + $experiment = new Content_Provenance(); + $signer = $experiment->get_public_signer(); + + $this->assertInstanceOf( + \WordPress\AI\Experiments\Content_Provenance\Signing\Signing_Interface::class, + $signer + ); + } + + /** + * Test get_public_signer returns Connected_Signer when tier is 'connected'. + * + * @since 0.5.0 + */ + public function test_get_public_signer_returns_connected_signer_for_connected_tier(): void { + update_option( 'ai_experiment_content-provenance_field_signing_tier', 'connected' ); + + $experiment = new Content_Provenance(); + $signer = $experiment->get_public_signer(); + + $this->assertInstanceOf( + \WordPress\AI\Experiments\Content_Provenance\Signing\Connected_Signer::class, + $signer + ); + + delete_option( 'ai_experiment_content-provenance_field_signing_tier' ); + } + + /** + * Test get_public_signer returns BYOK_Signer when tier is 'byok'. + * + * @since 0.5.0 + */ + public function test_get_public_signer_returns_byok_signer_for_byok_tier(): void { + update_option( 'ai_experiment_content-provenance_field_signing_tier', 'byok' ); + + $experiment = new Content_Provenance(); + $signer = $experiment->get_public_signer(); + + $this->assertInstanceOf( + \WordPress\AI\Experiments\Content_Provenance\Signing\BYOK_Signer::class, + $signer + ); + + delete_option( 'ai_experiment_content-provenance_field_signing_tier' ); + } + + /** + * Test REST verify endpoint returns 'unsigned' for plain text. + * + * @since 0.5.0 + */ + public function test_rest_verify_returns_unsigned_for_plain_text(): void { + $experiment = new Content_Provenance(); + $experiment->register(); + $GLOBALS['wp_rest_server'] = null; + rest_get_server(); + + $request = new \WP_REST_Request( 'POST', '/c2pa-provenance/v1/verify' ); + $request->set_param( 'text', 'This is plain unsigned text.' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'unsigned', $data['status'] ); + $this->assertFalse( $data['verified'] ); + } + + /** + * Test REST verify endpoint returns 'verified' for signed text. + * + * @since 0.5.0 + */ + public function test_rest_verify_returns_verified_for_signed_text(): void { + $keypair = $this->generate_test_keypair(); + $signer = new \WordPress\AI\Experiments\Content_Provenance\Signing\Local_Signer( $keypair ); + + $content = 'REST verify test content.'; + $built = \WordPress\AI\Experiments\Content_Provenance\C2PA_Manifest_Builder::build( + $content, + 'c2pa.created', + null, + array(), + $signer + ); + $this->assertIsArray( $built ); + $signed_text = \WordPress\AI\Experiments\Content_Provenance\Unicode_Embedder::embed( $content, $built['manifest'] ); + + $experiment = new Content_Provenance(); + $experiment->register(); + $GLOBALS['wp_rest_server'] = null; + rest_get_server(); + + $request = new \WP_REST_Request( 'POST', '/c2pa-provenance/v1/verify' ); + $request->set_param( 'text', $signed_text ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'verified', $data['status'] ); + $this->assertTrue( $data['verified'] ); + } + + /** + * Test REST status endpoint returns 'unsigned' for a new post. + * + * @since 0.5.0 + */ + public function test_rest_status_returns_unsigned_for_new_post(): void { + $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + + $experiment = new Content_Provenance(); + $experiment->register(); + $GLOBALS['wp_rest_server'] = null; + rest_get_server(); + + $request = new \WP_REST_Request( 'GET', '/c2pa-provenance/v1/status' ); + $request->set_param( 'post_id', $post_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'unsigned', $data['status'] ); + $this->assertNull( $data['signed_at'] ); + + wp_set_current_user( 0 ); + } + + /** + * Test REST status endpoint returns signing data after a post is signed. + * + * @since 0.5.0 + */ + public function test_rest_status_returns_signed_after_signing(): void { + $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $keypair = $this->generate_test_keypair(); + update_option( '_c2pa_local_keypair', $keypair ); + + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Content to sign for status test.', + ) + ); + $post = get_post( $post_id ); + + $experiment = new Content_Provenance(); + $experiment->sign_post( $post_id, $post, 'c2pa.created' ); + + $experiment->register(); + $GLOBALS['wp_rest_server'] = null; + rest_get_server(); + + $request = new \WP_REST_Request( 'GET', '/c2pa-provenance/v1/status' ); + $request->set_param( 'post_id', $post_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'signed', $data['status'] ); + $this->assertNotEmpty( $data['signed_at'] ); + $this->assertSame( 'local', $data['signer_tier'] ); + + wp_set_current_user( 0 ); + } + + /** + * Test register_settings registers the expected options. + * + * @since 0.5.0 + */ + public function test_register_settings_registers_signing_tier(): void { + $experiment = new Content_Provenance(); + $experiment->register_settings(); + + global $wp_registered_settings; + // Option name follows the pattern: ai_experiment_{id}_field_{name}. + $option_name = 'ai_experiment_content-provenance_field_signing_tier'; + $this->assertArrayHasKey( $option_name, $wp_registered_settings ); + } + + /** + * Test that render_settings_fields() outputs the settings fieldset HTML. + * + * @since 0.5.0 + */ + public function test_render_settings_fields_outputs_fieldset(): void { + $experiment = new Content_Provenance(); + ob_start(); + $experiment->render_settings_fields(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'ai-experiment-content-provenance-settings', $output ); + $this->assertStringContainsString( 'ai_experiment_content-provenance_field_signing_tier', $output ); + $this->assertStringContainsString( 'ai_experiment_content-provenance_field_auto_sign', $output ); + } + + /** + * Test that render_settings_fields() marks the connected section open when tier is connected. + * + * @since 0.5.0 + */ + public function test_render_settings_fields_shows_connected_tier_open(): void { + update_option( 'ai_experiment_content-provenance_field_signing_tier', 'connected' ); + + $experiment = new Content_Provenance(); + ob_start(); + $experiment->render_settings_fields(); + $output = ob_get_clean(); + + // The
element for connected should have the 'open' attribute. + $this->assertMatchesRegularExpression( '/]*open[^>]*>/', $output ); + + delete_option( 'ai_experiment_content-provenance_field_signing_tier' ); + } + + /** + * Test that render_settings_fields() marks the byok section open when tier is byok. + * + * @since 0.5.0 + */ + public function test_render_settings_fields_shows_byok_tier_open(): void { + update_option( 'ai_experiment_content-provenance_field_signing_tier', 'byok' ); + + $experiment = new Content_Provenance(); + ob_start(); + $experiment->render_settings_fields(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'byok_certificate', $output ); + $this->assertMatchesRegularExpression( '/]*open[^>]*>/', $output ); + + delete_option( 'ai_experiment_content-provenance_field_signing_tier' ); + } + + /** + * Test that add_well_known_rewrite() registers the c2pa_well_known query var. + * + * @since 0.5.0 + */ + public function test_add_well_known_rewrite_registers_query_var(): void { + $experiment = new Content_Provenance(); + $experiment->add_well_known_rewrite(); + + $vars = apply_filters( 'query_vars', array() ); + + $this->assertContains( 'c2pa_well_known', $vars ); + } + + /** + * Test that handle_well_known_request() returns early when query var is not set. + * + * @since 0.5.0 + */ + public function test_handle_well_known_request_returns_early_without_query_var(): void { + // The query var 'c2pa_well_known' is not set, so get_query_var() returns ''. + $experiment = new Content_Provenance(); + ob_start(); + $experiment->handle_well_known_request(); + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + } + + /** + * Test that enqueue_assets() returns early when there is no current screen. + * + * @since 0.5.0 + */ + public function test_enqueue_assets_returns_early_without_screen(): void { + // In the test context get_current_screen() returns null. + $experiment = new Content_Provenance(); + $experiment->enqueue_assets(); + + $this->assertFalse( wp_script_is( 'ai_content_provenance', 'enqueued' ) ); + } + + /** + * Test that enqueue_assets() returns early for a non-post admin screen. + * + * @since 0.5.0 + */ + public function test_enqueue_assets_returns_early_for_non_post_screen(): void { + set_current_screen( 'options-general' ); + + $experiment = new Content_Provenance(); + $experiment->enqueue_assets(); + + $this->assertFalse( wp_script_is( 'ai_content_provenance', 'enqueued' ) ); + + // Restore: unset the current screen global. + unset( $GLOBALS['current_screen'] ); + } + + /** + * Test that enqueue_assets() proceeds through the post screen path. + * + * Asset_Loader returns early when the build file doesn't exist (test env), + * so no actual script is enqueued, but all code paths in enqueue_assets() run. + * + * @since 0.5.0 + */ + public function test_enqueue_assets_runs_on_post_screen(): void { + set_current_screen( 'post' ); + + $experiment = new Content_Provenance(); + $experiment->enqueue_assets(); + + // In test environment the build file does not exist, so Asset_Loader returns early. + $this->assertFalse( wp_script_is( 'ai_content_provenance', 'enqueued' ) ); + + unset( $GLOBALS['current_screen'] ); + } + + /** + * Test that C2PA_Manifest_Builder::build() returns WP_Error when signer fails. + * + * @since 0.5.0 + */ + public function test_manifest_builder_build_returns_error_when_signer_fails(): void { + $mock_signer = new class() implements \WordPress\AI\Experiments\Content_Provenance\Signing\Signing_Interface { + /** + * Always fail. + * + * @param string $content Content. + * @param array $claims Claims. + * @return \WP_Error + */ + public function sign( string $content, array $claims ) { + return new \WP_Error( 'mock_signer_error', 'Intentional test failure.' ); + } + + /** + * Tier name. + * + * @return string + */ + public function get_tier(): string { + return 'mock'; + } + }; + + $result = C2PA_Manifest_Builder::build( 'Some content.', 'c2pa.created', null, array(), $mock_signer ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'mock_signer_error', $result->get_error_code() ); + } + + /** + * Test that C2PA_Manifest_Builder::extract_and_verify() returns 'invalid' for non-JSON embedded data. + * + * @since 0.5.0 + */ + public function test_manifest_builder_extract_and_verify_returns_invalid_for_non_json(): void { + // embed() with a non-JSON string produces a wrapper that extract() decodes, + // but json_decode() then fails, returning the 'invalid' status. + $signed_text = Unicode_Embedder::embed( 'Content.', 'this-is-not-valid-json' ); + $result = C2PA_Manifest_Builder::extract_and_verify( $signed_text ); + + $this->assertSame( 'invalid', $result['status'] ); + $this->assertFalse( $result['verified'] ); + $this->assertNull( $result['manifest'] ); + } + + /** + * Generate a test RSA keypair for use in tests. + * + * @since 0.5.0 + * @return array{private_key: string, public_key: string} + */ + private function generate_test_keypair(): array { + $res = openssl_pkey_new( + array( + 'private_key_bits' => 1024, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + openssl_pkey_export( $res, $private_key ); + $details = openssl_pkey_get_details( $res ); + return array( + 'private_key' => $private_key, + 'public_key' => $details['key'], + ); + } +} diff --git a/tests/Integration/Includes/Experiments/Content_Provenance/Signing/BYOK_SignerTest.php b/tests/Integration/Includes/Experiments/Content_Provenance/Signing/BYOK_SignerTest.php new file mode 100644 index 00000000..96f0e784 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Content_Provenance/Signing/BYOK_SignerTest.php @@ -0,0 +1,127 @@ + 1024, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + openssl_pkey_export( $res, $pem ); + + $this->temp_key_file = sys_get_temp_dir() . '/byok_test_' . uniqid() . '.pem'; + file_put_contents( $this->temp_key_file, $pem ); + } + + /** + * Tear down: remove temporary key file. + * + * @since 0.5.0 + */ + public function tearDown(): void { + if ( $this->temp_key_file && file_exists( $this->temp_key_file ) ) { + unlink( $this->temp_key_file ); + } + parent::tearDown(); + } + + /** + * Test that get_tier returns 'byok'. + * + * @since 0.5.0 + */ + public function test_get_tier_returns_byok(): void { + $signer = new BYOK_Signer( '/some/path.pem' ); + $this->assertSame( 'byok', $signer->get_tier() ); + } + + /** + * Test that sign() with empty cert_path returns WP_Error. + * + * @since 0.5.0 + */ + public function test_sign_with_empty_cert_path_returns_error(): void { + $signer = new BYOK_Signer( '' ); + $result = $signer->sign( 'Content.', array() ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'c2pa_byok_no_cert', $result->get_error_code() ); + } + + /** + * Test that sign() with an unreadable cert path returns WP_Error. + * + * @since 0.5.0 + */ + public function test_sign_with_unreadable_cert_path_returns_error(): void { + $signer = new BYOK_Signer( '/nonexistent/path/key.pem' ); + $result = $signer->sign( 'Content.', array() ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'c2pa_byok_cert_unreadable', $result->get_error_code() ); + } + + /** + * Test that sign() with a valid key file returns a signed manifest string. + * + * @since 0.5.0 + */ + public function test_sign_with_valid_key_returns_manifest_string(): void { + $signer = new BYOK_Signer( $this->temp_key_file ); + $result = $signer->sign( 'Test content.', array( 'title' => 'Test' ) ); + + $this->assertIsString( $result ); + $decoded = json_decode( $result, true ); + $this->assertIsArray( $decoded ); + $this->assertArrayHasKey( 'signature', $decoded ); + $this->assertSame( 'byok', $decoded['signer'] ); + } + + /** + * Test that sign() with a valid key file includes the public key in the manifest. + * + * @since 0.5.0 + */ + public function test_sign_includes_public_key_in_manifest(): void { + $signer = new BYOK_Signer( $this->temp_key_file ); + $result = $signer->sign( 'Content.', array() ); + + $this->assertIsString( $result ); + $decoded = json_decode( $result, true ); + $this->assertNotEmpty( $decoded['public_key'] ); + } +} diff --git a/tests/Integration/Includes/Experiments/Content_Provenance/Signing/Connected_SignerTest.php b/tests/Integration/Includes/Experiments/Content_Provenance/Signing/Connected_SignerTest.php new file mode 100644 index 00000000..ecac5a12 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Content_Provenance/Signing/Connected_SignerTest.php @@ -0,0 +1,159 @@ +assertSame( 'connected', $signer->get_tier() ); + } + + /** + * Test that sign() with empty service URL returns WP_Error. + * + * @since 0.5.0 + */ + public function test_sign_with_empty_url_returns_error(): void { + $signer = new Connected_Signer( '', 'api-key' ); + $result = $signer->sign( 'Content.', array() ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'c2pa_connected_no_url', $result->get_error_code() ); + } + + /** + * Test that sign() returns WP_Error when the HTTP request itself fails. + * + * @since 0.5.0 + */ + public function test_sign_returns_error_on_http_failure(): void { + add_filter( + 'pre_http_request', + static function () { + return new \WP_Error( 'http_request_failed', 'Connection refused.' ); + } + ); + + $signer = new Connected_Signer( 'https://example.com/sign', 'api-key' ); + $result = $signer->sign( 'Content.', array() ); + + remove_all_filters( 'pre_http_request' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'c2pa_connected_request_failed', $result->get_error_code() ); + } + + /** + * Test that sign() returns WP_Error on a non-200 HTTP response. + * + * @since 0.5.0 + */ + public function test_sign_returns_error_on_bad_status_code(): void { + add_filter( + 'pre_http_request', + static function () { + return array( + 'response' => array( + 'code' => 500, + 'message' => 'Internal Server Error', + ), + 'body' => '', + 'headers' => array(), + ); + } + ); + + $signer = new Connected_Signer( 'https://example.com/sign', 'api-key' ); + $result = $signer->sign( 'Content.', array() ); + + remove_all_filters( 'pre_http_request' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'c2pa_connected_bad_response', $result->get_error_code() ); + } + + /** + * Test that sign() returns WP_Error when response body is missing the manifest key. + * + * @since 0.5.0 + */ + public function test_sign_returns_error_on_invalid_response_body(): void { + add_filter( + 'pre_http_request', + static function () { + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => wp_json_encode( array( 'unexpected' => 'data' ) ), + 'headers' => array(), + ); + } + ); + + $signer = new Connected_Signer( 'https://example.com/sign', 'api-key' ); + $result = $signer->sign( 'Content.', array() ); + + remove_all_filters( 'pre_http_request' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'c2pa_connected_invalid_response', $result->get_error_code() ); + } + + /** + * Test that sign() returns the manifest string on a successful response. + * + * @since 0.5.0 + */ + public function test_sign_returns_manifest_on_success(): void { + $manifest = wp_json_encode( + array( + 'magic' => 'test', + 'signer' => 'connected', + ) + ); + + add_filter( + 'pre_http_request', + static function () use ( $manifest ) { + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => wp_json_encode( array( 'manifest' => $manifest ) ), + 'headers' => array(), + ); + } + ); + + $signer = new Connected_Signer( 'https://example.com/sign', 'api-key' ); + $result = $signer->sign( 'Content.', array() ); + + remove_all_filters( 'pre_http_request' ); + + $this->assertSame( $manifest, $result ); + } +} diff --git a/tests/Integration/Includes/Experiments/Content_Provenance/Signing/Local_SignerTest.php b/tests/Integration/Includes/Experiments/Content_Provenance/Signing/Local_SignerTest.php new file mode 100644 index 00000000..c677da09 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Content_Provenance/Signing/Local_SignerTest.php @@ -0,0 +1,108 @@ + '', + 'public_key' => '', + ) + ); + $this->assertSame( 'local', $signer->get_tier() ); + } + + /** + * Test that sign() returns a JSON string with a valid keypair. + * + * @since 0.5.0 + */ + public function test_sign_with_valid_keypair_returns_string(): void { + $keypair = $this->generate_test_keypair(); + $signer = new Local_Signer( $keypair ); + + $result = $signer->sign( 'Test content.', array( 'title' => 'Test' ) ); + + $this->assertIsString( $result ); + $decoded = json_decode( $result, true ); + $this->assertIsArray( $decoded ); + $this->assertArrayHasKey( 'signature', $decoded ); + $this->assertSame( 'local', $decoded['signer'] ); + } + + /** + * Test that sign() with invalid private key returns WP_Error. + * + * @since 0.5.0 + */ + public function test_sign_with_invalid_private_key_returns_error(): void { + $signer = new Local_Signer( + array( + 'private_key' => 'not-a-key', + 'public_key' => '', + ) + ); + $result = $signer->sign( 'Test.', array() ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'c2pa_key_load_failed', $result->get_error_code() ); + } + + /** + * Test that sign() embeds the public key in the manifest. + * + * @since 0.5.0 + */ + public function test_sign_embeds_public_key(): void { + $keypair = $this->generate_test_keypair(); + $signer = new Local_Signer( $keypair ); + $result = $signer->sign( 'Content.', array() ); + + $this->assertIsString( $result ); + $decoded = json_decode( $result, true ); + $this->assertSame( $keypair['public_key'], $decoded['public_key'] ); + } + + /** + * Generate a test RSA keypair. + * + * @since 0.5.0 + * @return array{private_key: string, public_key: string} + */ + private function generate_test_keypair(): array { + $res = openssl_pkey_new( + array( + 'private_key_bits' => 1024, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + openssl_pkey_export( $res, $private_key ); + $details = openssl_pkey_get_details( $res ); + return array( + 'private_key' => $private_key, + 'public_key' => $details['key'], + ); + } +} diff --git a/tests/Integration/Includes/Experiments/Content_Provenance/Unicode_EmbedderTest.php b/tests/Integration/Includes/Experiments/Content_Provenance/Unicode_EmbedderTest.php new file mode 100644 index 00000000..3fc3daa6 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Content_Provenance/Unicode_EmbedderTest.php @@ -0,0 +1,207 @@ +assertStringStartsWith( $original, $embedded ); + // The embedded string must be longer than the original. + $this->assertGreaterThan( strlen( $original ), strlen( $embedded ) ); + } + + /** + * Test that embed() includes the U+FEFF prefix in the wrapper. + * + * @since 0.5.0 + */ + public function test_embed_contains_feff_marker(): void { + $embedded = Unicode_Embedder::embed( 'Text.', '{"x":1}' ); + $this->assertStringContainsString( Unicode_Embedder::PREFIX, $embedded ); + } + + /** + * Test that extract() returns null when no U+FEFF marker is present. + * + * @since 0.5.0 + */ + public function test_extract_returns_null_for_no_feff_marker(): void { + $this->assertNull( Unicode_Embedder::extract( 'Plain text, no marker.' ) ); + } + + /** + * Test that extract() returns null when U+FEFF is present but not followed + * by variation-selector bytes (fewer than HEADER_SIZE decoded bytes). + * + * @since 0.5.0 + */ + public function test_extract_returns_null_for_feff_without_vs_bytes(): void { + // U+FEFF followed by plain ASCII — no variation selectors. + $text = 'Hello' . Unicode_Embedder::PREFIX . 'World'; + $this->assertNull( Unicode_Embedder::extract( $text ) ); + } + + /** + * Test that extract() returns null when fewer than HEADER_SIZE (13) bytes + * are encoded after the U+FEFF marker. + * + * @since 0.5.0 + */ + public function test_extract_returns_null_for_insufficient_bytes(): void { + // Only 5 VS bytes encoded — less than the 13-byte header requirement. + $bytes = array( 0, 1, 2, 3, 4 ); + $text = 'Hello' . $this->build_vs_string( $bytes ); + $this->assertNull( Unicode_Embedder::extract( $text ) ); + } + + /** + * Test that extract() returns null when magic bytes are wrong. + * + * @since 0.5.0 + */ + public function test_extract_returns_null_for_wrong_magic(): void { + // Header with all-zero magic (should be C2PATXT\0 = 0x43,0x32,0x50,0x41,0x54,0x58,0x54,0x00). + $bytes = array_merge( + array_fill( 0, 8, 0 ), // wrong magic: all zeros + array( 1 ), // version = 1 + array( 0, 0, 0, 5 ), // length = 5 + array( 65, 66, 67, 68, 69 ) // payload: 'ABCDE' + ); + $text = 'Hello' . $this->build_vs_string( $bytes ); + $this->assertNull( Unicode_Embedder::extract( $text ) ); + } + + /** + * Test that extract() returns null when the version byte is wrong. + * + * @since 0.5.0 + */ + public function test_extract_returns_null_for_wrong_version(): void { + // Correct magic, but version = 2 (spec requires version = 1). + $bytes = array_merge( + $this->get_magic_bytes(), // correct C2PATXT\0 magic + array( 2 ), // wrong version + array( 0, 0, 0, 5 ), // length = 5 + array( 65, 66, 67, 68, 69 ) // payload + ); + $text = 'Hello' . $this->build_vs_string( $bytes ); + $this->assertNull( Unicode_Embedder::extract( $text ) ); + } + + /** + * Test that extract() returns null when manifest length is zero. + * + * @since 0.5.0 + */ + public function test_extract_returns_null_for_zero_length_manifest(): void { + // embed() with an empty manifest string produces a wrapper with length=0. + $embedded = Unicode_Embedder::embed( 'Hello', '' ); + $this->assertNull( Unicode_Embedder::extract( $embedded ) ); + } + + /** + * Test that strip() leaves plain text without selectors unchanged. + * + * @since 0.5.0 + */ + public function test_strip_leaves_plain_text_unchanged(): void { + $this->assertSame( 'Plain text.', Unicode_Embedder::strip( 'Plain text.' ) ); + } + + /** + * Test that strip() removes the U+FEFF byte from text. + * + * @since 0.5.0 + */ + public function test_strip_removes_feff_marker(): void { + // Text with a bare U+FEFF marker and no VS selectors. + $text = 'Hello' . Unicode_Embedder::PREFIX . 'World'; + $stripped = Unicode_Embedder::strip( $text ); + $this->assertSame( 'HelloWorld', $stripped ); + } + + /** + * Test that strip() removes VS1–VS16 (U+FE00–U+FE0F) selectors. + * + * @since 0.5.0 + */ + public function test_strip_removes_vs1_to_vs16_selectors(): void { + // Manually construct a string with a VS1 selector (U+FE00 = EF B8 80). + $text = 'Hello' . "\xEF\xB8\x80" . 'World'; + $stripped = Unicode_Embedder::strip( $text ); + $this->assertSame( 'HelloWorld', $stripped ); + } + + /** + * Test that strip() removes VS17–VS256 (U+E0100–U+E01EF) selectors. + * + * @since 0.5.0 + */ + public function test_strip_removes_vs17_to_vs256_selectors(): void { + // U+E0100 = F3 A0 84 80 (first VS17 selector). + $text = 'Hello' . "\xF3\xA0\x84\x80" . 'World'; + $stripped = Unicode_Embedder::strip( $text ); + $this->assertSame( 'HelloWorld', $stripped ); + } + + /** + * Build a VS-encoded string with U+FEFF prefix from raw byte values. + * + * Mirrors the encoding logic in Unicode_Embedder::embed() for test construction. + * + * @since 0.5.0 + * + * @param array $bytes Raw byte values (0–255). + * @return string U+FEFF prefix followed by VS-encoded bytes. + */ + private function build_vs_string( array $bytes ): string { + $result = Unicode_Embedder::PREFIX; + foreach ( $bytes as $byte ) { + if ( $byte < 16 ) { + $result .= "\xEF\xB8" . chr( 0x80 + $byte ); + } else { + $n = $byte - 16; + $result .= "\xF3\xA0" . chr( 0x84 + intdiv( $n, 64 ) ) . chr( 0x80 + ( $n % 64 ) ); + } + } + return $result; + } + + /** + * Return the C2PA wrapper magic bytes as an integer array. + * + * @since 0.5.0 + * @return array + */ + private function get_magic_bytes(): array { + $unpacked = unpack( 'C*', "\x43\x32\x50\x41\x54\x58\x54\x00" ); + return array_values( $unpacked ? $unpacked : array() ); + } +} diff --git a/tests/Integration/Includes/Experiments/Content_Provenance/Verification_BadgeTest.php b/tests/Integration/Includes/Experiments/Content_Provenance/Verification_BadgeTest.php new file mode 100644 index 00000000..f4dc909c --- /dev/null +++ b/tests/Integration/Includes/Experiments/Content_Provenance/Verification_BadgeTest.php @@ -0,0 +1,143 @@ +assertGreaterThan( 0, has_filter( 'the_content', array( Verification_Badge::class, 'maybe_append_badge' ) ) ); + } + + /** + * Test that maybe_append_badge returns content unchanged when is_singular() is false. + * + * @since 0.5.0 + */ + public function test_maybe_append_badge_returns_unchanged_on_archive(): void { + // Not in a singular context by default in tests. + $content = '

Hello World

'; + $result = Verification_Badge::maybe_append_badge( $content ); + $this->assertSame( $content, $result ); + } + + /** + * Test that maybe_append_badge returns content unchanged when post is not signed. + * + * @since 0.5.0 + */ + public function test_maybe_append_badge_returns_unchanged_for_unsigned_post(): void { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $this->go_to( get_permalink( $post_id ) ); + + $content = '

Post content.

'; + $result = Verification_Badge::maybe_append_badge( $content ); + + // No '_c2pa_status' = 'signed' meta → unchanged. + $this->assertSame( $content, $result ); + } + + /** + * Test that maybe_append_badge appends badge HTML for a signed post. + * + * @since 0.5.0 + */ + public function test_maybe_append_badge_appends_badge_for_signed_post(): void { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + update_post_meta( $post_id, '_c2pa_status', 'signed' ); + update_post_meta( $post_id, '_c2pa_signer_tier', 'local' ); + + $this->go_to( get_permalink( $post_id ) ); + + $content = '

Post content.

'; + $result = Verification_Badge::maybe_append_badge( $content ); + + $this->assertStringContainsString( '

Post content.

', $result ); + $this->assertStringContainsString( 'c2pa-badge', $result ); + $this->assertStringContainsString( 'Content Integrity Verified', $result ); + } + + /** + * Test that badge shows "Verified Publisher" for connected tier. + * + * @since 0.5.0 + */ + public function test_badge_label_for_connected_tier(): void { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + update_post_meta( $post_id, '_c2pa_status', 'signed' ); + update_post_meta( $post_id, '_c2pa_signer_tier', 'connected' ); + + $this->go_to( get_permalink( $post_id ) ); + + $result = Verification_Badge::maybe_append_badge( '

Content

' ); + $this->assertStringContainsString( 'Verified Publisher', $result ); + } + + /** + * Test that badge shows "Publisher Certificate" for byok tier. + * + * @since 0.5.0 + */ + public function test_badge_label_for_byok_tier(): void { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + update_post_meta( $post_id, '_c2pa_status', 'signed' ); + update_post_meta( $post_id, '_c2pa_signer_tier', 'byok' ); + + $this->go_to( get_permalink( $post_id ) ); + + $result = Verification_Badge::maybe_append_badge( '

Content

' ); + $this->assertStringContainsString( 'Publisher Certificate', $result ); + } + + /** + * Test that the badge contains the verify REST URL. + * + * @since 0.5.0 + */ + public function test_badge_contains_verify_url(): void { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + update_post_meta( $post_id, '_c2pa_status', 'signed' ); + + $this->go_to( get_permalink( $post_id ) ); + + $result = Verification_Badge::maybe_append_badge( '

Content

' ); + $this->assertStringContainsString( 'c2pa-provenance/v1/verify', $result ); + } + + /** + * Test that the badge shows the sign date when signed_at meta is present. + * + * @since 0.5.0 + */ + public function test_badge_shows_date_when_signed_at_set(): void { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + update_post_meta( $post_id, '_c2pa_status', 'signed' ); + update_post_meta( $post_id, '_c2pa_signed_at', '2024-01-15T10:30:00+00:00' ); + + $this->go_to( get_permalink( $post_id ) ); + + $result = Verification_Badge::maybe_append_badge( '

Content

' ); + $this->assertStringContainsString( 'Signed ', $result ); + $this->assertStringContainsString( 'c2pa-badge__date', $result ); + } +} diff --git a/tests/Integration/Includes/Experiments/Example_Experiment/Example_ExperimentTest.php b/tests/Integration/Includes/Experiments/Example_Experiment/Example_ExperimentTest.php index 685573ba..92dd982c 100644 --- a/tests/Integration/Includes/Experiments/Example_Experiment/Example_ExperimentTest.php +++ b/tests/Integration/Includes/Experiments/Example_Experiment/Example_ExperimentTest.php @@ -49,6 +49,11 @@ public function setUp(): void { $experiment = $registry->get_experiment( 'example-experiment' ); $this->assertInstanceOf( Example_Experiment::class, $experiment, 'Example experiment should be registered in the registry.' ); + + // Force REST server re-initialization so routes hooked to rest_api_init + // during initialize_experiments() are registered before test methods run. + $GLOBALS['wp_rest_server'] = null; + rest_get_server(); } /** diff --git a/tests/Integration/Includes/Experiments/Image_Provenance/Image_ProvenanceTest.php b/tests/Integration/Includes/Experiments/Image_Provenance/Image_ProvenanceTest.php new file mode 100644 index 00000000..7742e4b8 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Image_Provenance/Image_ProvenanceTest.php @@ -0,0 +1,453 @@ +assertSame( 'image-provenance', $experiment->get_id() ); + $this->assertSame( 'Image Provenance', $experiment->get_label() ); + $this->assertSame( Experiment_Category::EDITOR, $experiment->get_category() ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Test that the experiment registers in the loader. + */ + public function test_experiment_is_in_default_experiments(): void { + $registry = new Experiment_Registry(); + $loader = new Experiment_Loader( $registry ); + $loader->register_default_experiments(); + + $experiment = $registry->get_experiment( 'image-provenance' ); + $this->assertInstanceOf( Image_Provenance::class, $experiment ); + } + + /** + * Test that a JPEG attachment gets signed on upload. + */ + public function test_signs_image_on_upload(): void { + $experiment = new Image_Provenance(); + $experiment->register(); + + // Create a fake JPEG attachment. + $attachment_id = $this->create_test_attachment( 'image/jpeg' ); + + // Trigger the add_attachment hook. + do_action( 'add_attachment', $attachment_id ); + + $status = get_post_meta( $attachment_id, '_c2pa_image_status', true ); + $this->assertSame( 'signed', $status ); + + $manifest = get_post_meta( $attachment_id, '_c2pa_image_manifest', true ); + $this->assertNotEmpty( $manifest ); + + $manifest_url = get_post_meta( $attachment_id, '_c2pa_image_manifest_url', true ); + $this->assertNotEmpty( $manifest_url ); + $this->assertStringContainsString( 'c2pa-provenance/v1/images/manifest', $manifest_url ); + + $signed_at = get_post_meta( $attachment_id, '_c2pa_image_signed_at', true ); + $this->assertNotEmpty( $signed_at ); + } + + /** + * Test that non-image attachments are skipped. + */ + public function test_skips_unsupported_mime_type(): void { + $experiment = new Image_Provenance(); + $experiment->register(); + + $attachment_id = $this->create_test_attachment( 'application/pdf' ); + + do_action( 'add_attachment', $attachment_id ); + + $status = get_post_meta( $attachment_id, '_c2pa_image_status', true ); + $this->assertEmpty( $status ); + } + + /** + * Test REST lookup returns the correct record for a known URL. + */ + public function test_rest_lookup_exact_url_match(): void { + $experiment = new Image_Provenance(); + $experiment->register(); + + $attachment_id = $this->create_test_attachment( 'image/jpeg' ); + do_action( 'add_attachment', $attachment_id ); + + $status = get_post_meta( $attachment_id, '_c2pa_image_status', true ); + if ( 'signed' !== $status ) { + $this->markTestSkipped( 'Image signing failed — OpenSSL may be unavailable.' ); + } + + $canonical_url = wp_get_attachment_url( $attachment_id ); + $this->assertNotFalse( $canonical_url ); + + $request = new \WP_REST_Request( 'GET', '/c2pa-provenance/v1/images/lookup' ); + $request->set_param( 'url', $canonical_url ); + + $response = $experiment->rest_lookup_callback( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( (string) $attachment_id, $data['record_id'] ); + } + + /** + * Test REST lookup strips CDN transform query params. + */ + public function test_rest_lookup_strips_cdn_params(): void { + $experiment = new Image_Provenance(); + $experiment->register(); + + $attachment_id = $this->create_test_attachment( 'image/jpeg' ); + do_action( 'add_attachment', $attachment_id ); + + $status = get_post_meta( $attachment_id, '_c2pa_image_status', true ); + if ( 'signed' !== $status ) { + $this->markTestSkipped( 'Image signing failed — OpenSSL may be unavailable.' ); + } + + $canonical_url = wp_get_attachment_url( $attachment_id ); + $this->assertNotFalse( $canonical_url ); + + // Add CDN transform parameters. + $cdn_url = add_query_arg( + array( + 'w' => '800', + 'format' => 'webp', + ), + $canonical_url + ); + + $request = new \WP_REST_Request( 'GET', '/c2pa-provenance/v1/images/lookup' ); + $request->set_param( 'url', $cdn_url ); + + $response = $experiment->rest_lookup_callback( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( (string) $attachment_id, $data['record_id'] ); + } + + /** + * Test REST lookup returns 404 for an unknown URL. + */ + public function test_rest_lookup_returns_404_for_unknown(): void { + $experiment = new Image_Provenance(); + $experiment->register(); + + $request = new \WP_REST_Request( 'GET', '/c2pa-provenance/v1/images/lookup' ); + $request->set_param( 'url', 'https://example.com/nonexistent-image.jpg' ); + + $response = $experiment->rest_lookup_callback( $request ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Test REST manifest endpoint returns stored JSON. + */ + public function test_rest_manifest_returns_json(): void { + $experiment = new Image_Provenance(); + $experiment->register(); + + $attachment_id = $this->create_test_attachment( 'image/jpeg' ); + do_action( 'add_attachment', $attachment_id ); + + $status = get_post_meta( $attachment_id, '_c2pa_image_status', true ); + if ( 'signed' !== $status ) { + $this->markTestSkipped( 'Image signing failed — OpenSSL may be unavailable.' ); + } + + $request = new \WP_REST_Request( 'GET', "/c2pa-provenance/v1/images/manifest/{$attachment_id}" ); + $request->set_param( 'attachment_id', $attachment_id ); + + $response = $experiment->rest_manifest_callback( $request ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + } + + /** + * Test that C2PA-Manifest-URL header is injected on singular pages with featured image. + */ + public function test_header_injection_on_singular(): void { + $experiment = new Image_Provenance(); + $experiment->register(); + + $attachment_id = $this->create_test_attachment( 'image/jpeg' ); + do_action( 'add_attachment', $attachment_id ); + + $status = get_post_meta( $attachment_id, '_c2pa_image_status', true ); + if ( 'signed' !== $status ) { + $this->markTestSkipped( 'Image signing failed — OpenSSL may be unavailable.' ); + } + + // Create a post with the attachment as featured image. + $post_id = $this->factory()->post->create( array( 'post_status' => 'publish' ) ); + set_post_thumbnail( $post_id, $attachment_id ); + + // Simulate is_singular() returning true and queried object being the post. + $this->go_to( get_permalink( $post_id ) ); + + // We can't easily assert headers in unit tests, but we can verify + // the manifest URL meta is set. + $manifest_url = get_post_meta( $attachment_id, '_c2pa_image_manifest_url', true ); + $this->assertNotEmpty( $manifest_url ); + } + + /** + * Test that no header is injected on non-singular pages. + */ + public function test_no_header_on_non_singular(): void { + // On archive pages, inject_manifest_url_header() should return early. + // We test this by checking that is_singular() is false on the home page. + $this->go_to( home_url() ); + $this->assertFalse( is_singular() ); + } + + /** + * Test that register_settings registers the auto_sign_images option. + */ + public function test_register_settings(): void { + $experiment = new Image_Provenance(); + $experiment->register_settings(); + + $registered = get_registered_settings(); + $option_name = 'ai_experiment_image-provenance_field_auto_sign_images'; + $this->assertArrayHasKey( $option_name, $registered ); + } + + /** + * Test that render_settings_fields outputs expected HTML. + */ + public function test_render_settings_fields(): void { + $experiment = new Image_Provenance(); + + ob_start(); + $experiment->render_settings_fields(); + $output = ob_get_clean(); + + $this->assertIsString( $output ); + $this->assertStringContainsString( 'ai-experiment-image-provenance-settings', $output ); + $this->assertStringContainsString( 'auto_sign_images', $output ); + $this->assertStringContainsString( 'type="checkbox"', $output ); + } + + /** + * Test that sign_on_attachment_upload skips attachments with no mime type. + */ + public function test_sign_on_attachment_upload_skips_empty_mime_type(): void { + $experiment = new Image_Provenance(); + + // Attachment with empty mime type. + $attachment_id = $this->factory()->attachment->create( + array( + 'post_mime_type' => '', + 'post_status' => 'inherit', + ) + ); + + $experiment->sign_on_attachment_upload( $attachment_id ); + + $status = get_post_meta( $attachment_id, '_c2pa_image_status', true ); + $this->assertEmpty( $status ); + } + + /** + * Test that sign_on_attachment_upload stores error status when URL cannot be retrieved. + */ + public function test_sign_on_attachment_upload_error_when_url_false(): void { + $experiment = new Image_Provenance(); + $attachment_id = $this->create_test_attachment( 'image/jpeg' ); + + // Force wp_get_attachment_url to return false. + add_filter( 'wp_get_attachment_url', '__return_false' ); + $experiment->sign_on_attachment_upload( $attachment_id ); + remove_filter( 'wp_get_attachment_url', '__return_false' ); + + $status = get_post_meta( $attachment_id, '_c2pa_image_status', true ); + $this->assertSame( 'error', $status ); + } + + /** + * Test that sign_on_attachment_upload stores error status when C2PA signing fails. + */ + public function test_sign_on_attachment_upload_error_when_signing_fails(): void { + // Store an invalid keypair so Local_Signer fails to load the private key. + update_option( + '_c2pa_local_keypair', + array( + 'private_key' => 'not-a-valid-pem', + 'public_key' => 'not-a-valid-pem', + ), + false + ); + + $experiment = new Image_Provenance(); + $attachment_id = $this->create_test_attachment( 'image/jpeg' ); + $experiment->sign_on_attachment_upload( $attachment_id ); + + $status = get_post_meta( $attachment_id, '_c2pa_image_status', true ); + $this->assertSame( 'error', $status ); + } + + /** + * Test that get_local_keypair returns the stored keypair without generating a new one. + */ + public function test_get_local_keypair_returns_stored_keypair(): void { + // Pre-populate a valid keypair. + $resource = openssl_pkey_new( + array( + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + + if ( false === $resource ) { + $this->markTestSkipped( 'OpenSSL unavailable.' ); + } + + $private_key_pem = ''; + openssl_pkey_export( $resource, $private_key_pem ); + $key_details = openssl_pkey_get_details( $resource ); + $public_key = is_array( $key_details ) ? ( $key_details['key'] ?? '' ) : ''; + + $stored_keypair = array( + 'private_key' => $private_key_pem, + 'public_key' => $public_key, + ); + update_option( '_c2pa_local_keypair', $stored_keypair, false ); + + // Signing should succeed using the stored keypair (not generate a new one). + $experiment = new Image_Provenance(); + $attachment_id = $this->create_test_attachment( 'image/jpeg' ); + $experiment->sign_on_attachment_upload( $attachment_id ); + + $status = get_post_meta( $attachment_id, '_c2pa_image_status', true ); + $this->assertSame( 'signed', $status ); + + // The stored option must not have changed (no new keypair generated). + $option_after = get_option( '_c2pa_local_keypair' ); + $this->assertSame( $stored_keypair, $option_after ); + } + + /** + * Test inject_manifest_url_header returns early when post has no thumbnail. + */ + public function test_inject_manifest_url_header_no_thumbnail(): void { + $experiment = new Image_Provenance(); + $experiment->register(); + + // Post with no featured image. + $post_id = $this->factory()->post->create( array( 'post_status' => 'publish' ) ); + $this->go_to( get_permalink( $post_id ) ); + + $this->assertTrue( is_singular() ); + // No thumbnail — inject_manifest_url_header should return early without error. + $experiment->inject_manifest_url_header(); + $this->assertTrue( true ); + } + + /** + * Test inject_manifest_url_header returns early when attachment has no manifest URL. + */ + public function test_inject_manifest_url_header_unsigned_attachment(): void { + $experiment = new Image_Provenance(); + $experiment->register(); + + // Attachment with no signing meta. + $attachment_id = $this->create_test_attachment( 'image/jpeg' ); + $post_id = $this->factory()->post->create( array( 'post_status' => 'publish' ) ); + set_post_thumbnail( $post_id, $attachment_id ); + + $this->go_to( get_permalink( $post_id ) ); + + $this->assertTrue( is_singular() ); + // No _c2pa_image_manifest_url meta — should return early. + $experiment->inject_manifest_url_header(); + $this->assertTrue( true ); + } + + /** + * Test rest_manifest_callback returns 404 for an attachment with no stored manifest. + */ + public function test_rest_manifest_returns_404_for_unsigned(): void { + $experiment = new Image_Provenance(); + $attachment_id = $this->create_test_attachment( 'image/jpeg' ); + + $request = new \WP_REST_Request( 'GET', "/c2pa-provenance/v1/images/manifest/{$attachment_id}" ); + $request->set_param( 'attachment_id', $attachment_id ); + + $response = $experiment->rest_manifest_callback( $request ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Creates a test attachment post with the given MIME type. + * + * @param string $mime_type The MIME type. + * @return int The attachment ID. + */ + private function create_test_attachment( string $mime_type ): int { + return $this->factory()->attachment->create( + array( + 'post_mime_type' => $mime_type, + 'post_status' => 'inherit', + 'post_title' => 'Test Image', + ) + ); + } +} diff --git a/tsconfig.json b/tsconfig.json index 87d245b4..9b653953 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,5 +60,5 @@ } }, "include": ["**/*.cjs", "**/*.mjs", "**/*.ts", "**/*.tsx"], - "exclude": ["**/build/*", "**/tests/**", "vendor/*", "node_modules/*"] + "exclude": ["**/build/*", "**/tests/**", "vendor/*", "node_modules/*", "cdn-workers/**"] } diff --git a/webpack.config.js b/webpack.config.js index bb929257..b83ccdfa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -64,6 +64,11 @@ module.exports = { 'src/experiments/alt-text-generation', 'media.ts' ), + 'experiments/content-provenance': path.resolve( + process.cwd(), + 'src/experiments/content-provenance', + 'index.js' + ), }, plugins: [