Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
build
node_modules
vendor
cdn-workers/
67 changes: 67 additions & 0 deletions cdn-workers/README.md
Original file line number Diff line number Diff line change
@@ -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
```
74 changes: 74 additions & 0 deletions cdn-workers/cloudflare/cdn-provenance-worker.js
Original file line number Diff line number Diff line change
@@ -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,
});
},
};
13 changes: 13 additions & 0 deletions cdn-workers/cloudflare/wrangler.toml.template
Original file line number Diff line number Diff line change
@@ -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"
70 changes: 70 additions & 0 deletions cdn-workers/fastly/main.rs
Original file line number Diff line number Diff line change
@@ -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<Response, Error> {
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::<serde_json::Value>(&body) {
if let Some(manifest_url) = json["manifest_url"].as_str() {
beresp.set_header("C2PA-Manifest-URL", manifest_url);
}
}
}
}
}

Ok(beresp)
}
64 changes: 64 additions & 0 deletions cdn-workers/lambda-edge/cdn-provenance-handler.mjs
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading