220 lines
7.4 KiB
TypeScript
220 lines
7.4 KiB
TypeScript
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<typeof createC2pa> | 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<typeof createC2pa> {
|
|
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<typeof createC2pa> {
|
|
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<string>();
|
|
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<Evidence[]> {
|
|
let reader: Awaited<ReturnType<Awaited<ReturnType<typeof createC2pa>>['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
|
|
}];
|
|
}
|