This commit is contained in:
Matěj Kubíček
2026-09-02 20:38:45 +02:00
parent 0c524ed6af
commit c7948a788a
46 changed files with 10099 additions and 152 deletions
+219
View File
@@ -0,0 +1,219 @@
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
}];
}