import { createC2pa } from '@contentauth/c2pa-web'; import type { Evidence } from '../shared/types'; const AI_SOURCE_TYPES = [ 'trainedalgorithmicmedia', 'compositewithtrainedalgorithmicmedia', 'algorithmicallyenhanced', 'algorithmicmedia' ]; interface RecordLike { [key: string]: unknown; } let c2paPromise: ReturnType | undefined; /** * c2pa-web 0.14.4 rejects every explicit worker URL whose protocol is not * `https:`, including Firefox's same-extension `moz-extension:` URLs. The * package only reads `protocol` and `toString()` before constructing Worker, * so this adapter lets its HTTPS-only guard validate while Worker still * receives the real, CSP-approved packaged URL. Keep this isolated until the * upstream SDK accepts browser-extension schemes directly. */ export class FirefoxExtensionWorkerUrl extends URL { readonly #packagedUrl: string; constructor(packagedUrl: string) { super('https://slopdetect.invalid/c2pa_worker.js'); if (!packagedUrl.startsWith('moz-extension:')) { throw new Error('C2PA worker must be a packaged Firefox extension resource.'); } this.#packagedUrl = packagedUrl; } override toString(): string { return this.#packagedUrl; } } async function initialiseC2pa(): ReturnType { const [trustList, timestampTrustList] = await Promise.all([ fetch(browser.runtime.getURL('trust/C2PA-TRUST-LIST.pem')).then((response) => response.text()), fetch(browser.runtime.getURL('trust/C2PA-TSA-TRUST-LIST.pem')).then((response) => response.text()) ]); return createC2pa({ wasmSrc: browser.runtime.getURL('c2pa_bg.wasm'), workerSrc: new FirefoxExtensionWorkerUrl(browser.runtime.getURL('c2pa_worker.js')), settings: { trust: { trustAnchors: `${trustList}\n${timestampTrustList}` }, verify: { verifyTrust: true, verifyAfterReading: true, verifyTimestampTrust: true, ocspFetch: false, remoteManifestFetch: false } } }); } function getC2pa(): ReturnType { c2paPromise ??= initialiseC2pa(); return c2paPromise; } function isRecord(value: unknown): value is RecordLike { return typeof value === 'object' && value !== null && !Array.isArray(value); } function walk(value: unknown, visit: (key: string, value: unknown) => void): void { if (Array.isArray(value)) { value.forEach((item) => walk(item, visit)); return; } if (!isRecord(value)) return; for (const [key, child] of Object.entries(value)) { visit(key, child); walk(child, visit); } } function collectStrings(value: unknown): string[] { const strings: string[] = []; walk(value, (_key, child) => { if (typeof child === 'string') strings.push(child); }); return strings; } function collectValidationCodes(value: unknown): string[] { const codes = new Set(); walk(value, (key, child) => { if (/^(code|status|validation_status|validationStatus)$/i.test(key) && typeof child === 'string') { codes.add(child); } if (/validation_status|validationStatus/i.test(key) && Array.isArray(child)) { for (const entry of child) { if (typeof entry === 'string') codes.add(entry); else if (isRecord(entry) && typeof entry.code === 'string') codes.add(entry.code); } } }); return [...codes]; } function findFirstString(value: unknown, matchingKeys: RegExp): string | undefined { let found: string | undefined; walk(value, (key, child) => { if (!found && matchingKeys.test(key) && typeof child === 'string') found = child; }); return found; } function hasTrustedCredential(value: unknown): boolean { let trusted = false; walk(value, (key, child) => { if (/trusted/i.test(key) && child === true) trusted = true; if (/validation_state|validationState/i.test(key) && child === 'Trusted') trusted = true; }); return trusted; } function hasInvalidValidationState(value: unknown): boolean { let invalid = false; walk(value, (key, child) => { if (/validation_state|validationState/i.test(key) && child === 'Invalid') invalid = true; }); return invalid; } function containsManifest(value: unknown): boolean { if (!isRecord(value)) return false; return Boolean(value.active_manifest || value.activeManifest || value.manifests || value.claims); } function isInvalidStatus(code: string): boolean { return /invalid|mismatch|malformed|error|failure|revoked|expired/i.test(code); } function serialisable(value: unknown): unknown { try { return JSON.parse(JSON.stringify(value, (_key, item) => (typeof item === 'bigint' ? item.toString() : item))); } catch { return undefined; } } function isMissingManifestError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /manifest.*(?:not found|absent)|jumbf.*not found|no claim|unsupported.*(?:mime|format)/i.test(message); } export async function inspectC2pa(blob: Blob, includeRaw: boolean): Promise { let reader: Awaited>['reader']['fromBlob']>> | undefined; try { const c2pa = await getC2pa(); reader = await c2pa.reader.fromBlob(blob.type || 'application/octet-stream', blob); if (!reader) return []; const store: unknown = await reader.manifestStore(); return evidenceFromManifestStore(store, includeRaw); } catch (error) { if (isMissingManifestError(error)) return []; const message = error instanceof Error ? error.message : String(error); return [{ id: crypto.randomUUID(), kind: 'limitation', strength: 'none', verdict: 'unknown', title: 'C2PA inspection failed', detail: message.slice(0, 300), detector: '@contentauth/c2pa-web' }]; } finally { await reader?.free(); } } export function evidenceFromManifestStore(store: unknown, includeRaw: boolean): Evidence[] { if (!containsManifest(store)) return []; const strings = collectStrings(store); const aiGenerated = strings.some((value) => { const normalised = value.toLowerCase().replace(/[^a-z]/g, ''); return AI_SOURCE_TYPES.some((type) => normalised.includes(type)); }); const statuses = collectValidationCodes(store); const invalid = hasInvalidValidationState(store) || statuses.some(isInvalidStatus); const trusted = hasTrustedCredential(store); const issuer = findFirstString(store, /issuer|common_name|organization|signer/i); if (invalid) { return [{ id: crypto.randomUUID(), kind: 'c2pa', strength: 'invalid', verdict: 'invalid', title: 'Invalid Content Credential', detail: `C2PA provenance was present but validation reported: ${statuses.join(', ') || 'an integrity error'}.`, detector: '@contentauth/c2pa-web', issuer, raw: includeRaw ? serialisable(store) : undefined }]; } return [{ id: crypto.randomUUID(), kind: 'c2pa', strength: trusted ? 'verified' : 'detected', verdict: aiGenerated ? 'ai' : 'not-ai-asserted', title: aiGenerated ? 'AI provenance credential' : 'Content Credential', detail: aiGenerated ? `A ${trusted ? 'trusted and valid' : 'readable but not trusted'} C2PA claim identifies AI generation or manipulation.` : `A ${trusted ? 'trusted and valid' : 'readable but not trusted'} C2PA claim was found without an AI-generation assertion.`, detector: '@contentauth/c2pa-web', issuer, raw: includeRaw ? serialisable(store) : undefined }]; }