From d2c6f58e39819c5c23252c74f36e88c3319d3d03 Mon Sep 17 00:00:00 2001 From: Tim Murphy Date: Tue, 5 May 2026 23:35:29 -0400 Subject: [PATCH 1/4] fix: restore ingredient thumbnails with c2pa-web --- src/lib/asset.ts | 55 +++++++++++++++------ src/lib/resolveThumbnails.ts | 66 ++++++++++++++++++++++++++ src/routes/verify/stores/c2paReader.ts | 5 +- 3 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 src/lib/resolveThumbnails.ts diff --git a/src/lib/asset.ts b/src/lib/asset.ts index c3d10613a..e0bab907b 100644 --- a/src/lib/asset.ts +++ b/src/lib/asset.ts @@ -35,7 +35,12 @@ import { } from './selectors/validationResult'; import { selectWeb3 } from './selectors/web3Info'; import { selectWebsite } from './selectors/website'; -import { loadThumbnail, type ThumbnailInfo } from './thumbnail'; +import { toAbsoluteIdentifier } from './resolveThumbnails'; +import { + loadThumbnail, + type ThumbnailInfo, + type ThumbnailResult, +} from './thumbnail'; import type { Disposable } from './types'; const MANIFEST_STORE_MIME_TYPE = 'application/x-c2pa-manifest-store'; @@ -126,9 +131,11 @@ export function getIngredientDataType( export async function resultToAssetMap({ manifestStore, source, + thumbnails, }: { manifestStore: ManifestStore; source: Blob | File; + thumbnails: Map; }): Promise { const assetMap: AssetDataMap = {}; const disposers: (() => void)[] = []; @@ -170,6 +177,26 @@ export async function resultToAssetMap({ } } + async function lookupThumbnail( + ref: Thumbnail | null | undefined, + containingManifestLabel: string, + ): Promise { + const blob = ref?.identifier + ? thumbnails.get(toAbsoluteIdentifier(ref.identifier, containingManifestLabel)) + : undefined; + + if (!blob) { + return loadThumbnail(ref?.format, undefined); + } + + const url = URL.createObjectURL(blob); + + return loadThumbnail(ref?.format, { + url, + dispose: () => URL.revokeObjectURL(url), + }); + } + if (!isManifest && (!manifestStore || hasError || hasOtgp)) { // Fallback for raw files: render the original source file directly const url = URL.createObjectURL(source); @@ -224,14 +251,11 @@ export async function resultToAssetMap({ runtimeValidationStatuses: ManifestLabelValidationStatusMap, id: string, ): Promise { - const manifest = manifestStore.manifests?.[manifestStore.active_manifest || '']; + const activeManifestLabel = manifestStore.active_manifest || ''; + const manifest = manifestStore.manifests?.[activeManifestLabel]; if (!manifest) throw new Error('Active manifest not found'); - // 0.17.x SDK dropped internal thumbnail generation. Pass undefined to skip WASM fetch. - let thumbnail = await loadThumbnail( - manifest.thumbnail?.format, - undefined - ); + let thumbnail = await lookupThumbnail(manifest.thumbnail, activeManifestLabel); if ( !thumbnail.info && @@ -241,7 +265,7 @@ export async function resultToAssetMap({ // Fallback for active manifest: render the original source file directly const url = URL.createObjectURL(source); thumbnail = await loadThumbnail( - source.type, + source.type, { url, dispose: () => URL.revokeObjectURL(url) } ); } @@ -256,6 +280,7 @@ export async function resultToAssetMap({ manifestStore, runtimeValidationStatuses, id, + activeManifestLabel, ), manifestData: await getManifestData(manifest, rootValidationResult), dataType: null, @@ -277,15 +302,14 @@ export async function resultToAssetMap({ manifestStore: ManifestStore, runtimeValidationStatuses: ManifestLabelValidationStatusMap, id: string, + containingManifestLabel: string, ): Promise { const ingredientManifestLabel = ingredient.active_manifest; const ingredientManifest = ingredientManifestLabel ? manifestStore.manifests?.[ingredientManifestLabel] : null; - // 0.17.x SDK dropped internal thumbnail generation. Skip WASM fetch for ingredients. - const thumbnail = await loadThumbnail( - ingredient.thumbnail?.format, - undefined, - ); + const thumbnail = ingredientManifest && ingredientManifest.thumbnail + ? await lookupThumbnail(ingredientManifest.thumbnail, ingredientManifestLabel ?? containingManifestLabel) + : await lookupThumbnail(ingredient.thumbnail, containingManifestLabel); const activeManifestValidationResults = ingredient.validation_results?.activeManifest ?? undefined; @@ -307,12 +331,13 @@ export async function resultToAssetMap({ title: ingredient.title ?? null, thumbnail: thumbnail.info, mimeType: ingredient.format || '', - children: (showChildren && ingredientManifest?.ingredients) + children: (showChildren && ingredientManifest?.ingredients && ingredientManifestLabel) ? await processIngredients( ingredientManifest.ingredients, manifestStore, runtimeValidationStatuses, id, + ingredientManifestLabel, ) : [], manifestData: await getManifestData(ingredientManifest, validationResult), @@ -462,6 +487,7 @@ export async function resultToAssetMap({ manifestStore: ManifestStore, runtimeValidationStatuses: ManifestLabelValidationStatusMap, id: string, + containingManifestLabel: string, ): Promise { const ingredientIds = ingredients.map(async (ingredient, idx) => { const ingredientId = `${id}.${idx}`; @@ -471,6 +497,7 @@ export async function resultToAssetMap({ manifestStore, runtimeValidationStatuses, ingredientId, + containingManifestLabel, ); return ingredientId; diff --git a/src/lib/resolveThumbnails.ts b/src/lib/resolveThumbnails.ts new file mode 100644 index 000000000..910c2b59a --- /dev/null +++ b/src/lib/resolveThumbnails.ts @@ -0,0 +1,66 @@ +// Copyright 2021-2024 Adobe, Copyright 2025 The C2PA Contributors + +import type { ManifestStore, Reader } from '@contentauth/c2pa-web'; + +/** + * Convert a relative thumbnail URI to absolute form. Already absolute + * identifiers pass through unchanged. + */ +export function toAbsoluteIdentifier( + identifier: string, + manifestLabel: string, +): string { + if (identifier.startsWith('self#jumbf=/c2pa/')) return identifier; + + if (identifier.startsWith('self#jumbf=')) { + const path = identifier.slice('self#jumbf='.length); + + return `self#jumbf=/c2pa/${manifestLabel}/${path}`; + } + + return identifier; +} + +/** Pre-fetch every embedded thumbnail's bytes, keyed by absolute identifier. */ +export async function resolveThumbnails( + manifestStore: ManifestStore, + reader: Reader, +): Promise> { + const refs = new Map(); + + for (const [label, manifest] of Object.entries(manifestStore.manifests || {})) { + if (manifest.thumbnail?.identifier && manifest.thumbnail.format) { + refs.set( + toAbsoluteIdentifier(manifest.thumbnail.identifier, label), + manifest.thumbnail.format, + ); + } + + for (const ingredient of manifest.ingredients || []) { + if (ingredient.thumbnail?.identifier && ingredient.thumbnail.format) { + refs.set( + toAbsoluteIdentifier(ingredient.thumbnail.identifier, label), + ingredient.thumbnail.format, + ); + } + } + } + + const entries = await Promise.all( + Array.from(refs.entries()).map( + async ([identifier, format]): Promise<[string, Blob] | null> => { + try { + const bytes = await reader.resourceToBytes(identifier); + + return [identifier, new Blob([new Uint8Array(bytes)], { type: format })]; + } catch (err) { + console.warn(`Failed to resolve thumbnail ${identifier}:`, err); + + return null; + } + }, + ), + ); + + return new Map(entries.filter((e): e is [string, Blob] => e !== null)); +} diff --git a/src/routes/verify/stores/c2paReader.ts b/src/routes/verify/stores/c2paReader.ts index 8180b0d99..22c1bac0b 100644 --- a/src/routes/verify/stores/c2paReader.ts +++ b/src/routes/verify/stores/c2paReader.ts @@ -1,6 +1,7 @@ // Copyright 2021-2024 Adobe, Copyright 2025 The C2PA Contributors import { resultToAssetMap, type AssetDataMap } from '$lib/asset'; +import { resolveThumbnails } from '$lib/resolveThumbnails'; import { getLegacySdk, getSdk, getOfficialToolkitSettings, getLegacyToolkitSettings } from '$lib/sdk'; import type { Loadable } from '$lib/types'; import { @@ -226,8 +227,10 @@ export function createC2paReader(): C2paReaderStore { }); } + const thumbnails = await resolveThumbnails(finalStore, currentReader); + const { assetMap, dispose: assetMapDisposer } = - await resultToAssetMap({ manifestStore: finalStore, source }); + await resultToAssetMap({ manifestStore: finalStore, source, thumbnails }); dispose = () => { assetMapDisposer(); From ab926d391280878e94dab985881d324a16b1960c Mon Sep 17 00:00:00 2001 From: Tim Murphy Date: Wed, 6 May 2026 22:11:57 -0400 Subject: [PATCH 2/4] fix: avoid silent fallback in ingredient thumbnail lookup --- src/lib/asset.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/asset.ts b/src/lib/asset.ts index e0bab907b..15e8179c8 100644 --- a/src/lib/asset.ts +++ b/src/lib/asset.ts @@ -307,8 +307,8 @@ export async function resultToAssetMap({ const ingredientManifestLabel = ingredient.active_manifest; const ingredientManifest = ingredientManifestLabel ? manifestStore.manifests?.[ingredientManifestLabel] : null; - const thumbnail = ingredientManifest && ingredientManifest.thumbnail - ? await lookupThumbnail(ingredientManifest.thumbnail, ingredientManifestLabel ?? containingManifestLabel) + const thumbnail = ingredientManifestLabel && ingredientManifest?.thumbnail + ? await lookupThumbnail(ingredientManifest.thumbnail, ingredientManifestLabel) : await lookupThumbnail(ingredient.thumbnail, containingManifestLabel); const activeManifestValidationResults = From 5885cae550a6f98d3cd1e0224be81909d53feec2 Mon Sep 17 00:00:00 2001 From: Tim Murphy Date: Thu, 7 May 2026 09:43:15 -0400 Subject: [PATCH 3/4] fix: suppress ingredient thumbnails from untrusted manifests --- src/lib/asset.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/lib/asset.ts b/src/lib/asset.ts index 15e8179c8..d92bd6be5 100644 --- a/src/lib/asset.ts +++ b/src/lib/asset.ts @@ -307,9 +307,26 @@ export async function resultToAssetMap({ const ingredientManifestLabel = ingredient.active_manifest; const ingredientManifest = ingredientManifestLabel ? manifestStore.manifests?.[ingredientManifestLabel] : null; + // The code prioritizes the signed ingredient's own c2pa.thumbnail.claim, if present, + // over the c2pa.thumbnail.ingredient assertion that the consuming manifest's signer + // references via ingredient.thumbnail. When a signed ingredient already provides + // its own claim thumbnail, a consuming signer's separate ingredient assertion + // thumbnail could be an intentional override pointing at a misleading image. The + // claim thumbnail more faithfully represents the ingredient. + // + // When no claim thumbnail is available for an ingredient, we fall back to + // c2pa.thumbnail.ingredient only if the containing manifest is trusted. If it is + // untrusted, we suppress the thumbnail so an untrusted signer can't force a false + // thumbnail to display for an ingredient that has no claim thumbnail of its own. A + // signed ingredient's own claim thumbnail is still shown when present (untrusted + // state is flagged in the UI). + const containingManifestTrust = (manifestStore.manifests?.[containingManifestLabel] as Manifest & { trust_source?: 'legacy' | 'none' | 'official' })?.trust_source; + const thumbnail = ingredientManifestLabel && ingredientManifest?.thumbnail ? await lookupThumbnail(ingredientManifest.thumbnail, ingredientManifestLabel) - : await lookupThumbnail(ingredient.thumbnail, containingManifestLabel); + : containingManifestTrust !== 'none' + ? await lookupThumbnail(ingredient.thumbnail, containingManifestLabel) + : await loadThumbnail(undefined, undefined); const activeManifestValidationResults = ingredient.validation_results?.activeManifest ?? undefined; From f6e732547a9add6189e7d6c10e2f0ca835ceafd2 Mon Sep 17 00:00:00 2001 From: Tim Murphy Date: Thu, 7 May 2026 10:45:00 -0400 Subject: [PATCH 4/4] fix: detect untrusted containing manifest via validation status --- src/lib/asset.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/asset.ts b/src/lib/asset.ts index d92bd6be5..a63bb04ae 100644 --- a/src/lib/asset.ts +++ b/src/lib/asset.ts @@ -320,11 +320,12 @@ export async function resultToAssetMap({ // thumbnail to display for an ingredient that has no claim thumbnail of its own. A // signed ingredient's own claim thumbnail is still shown when present (untrusted // state is flagged in the UI). - const containingManifestTrust = (manifestStore.manifests?.[containingManifestLabel] as Manifest & { trust_source?: 'legacy' | 'none' | 'official' })?.trust_source; + const containingManifestUntrusted = (runtimeValidationStatuses[containingManifestLabel] ?? []) + .some((s) => s.code.includes('signingCredential.untrusted') || s.code.includes('signingCredential.invalid')); const thumbnail = ingredientManifestLabel && ingredientManifest?.thumbnail ? await lookupThumbnail(ingredientManifest.thumbnail, ingredientManifestLabel) - : containingManifestTrust !== 'none' + : !containingManifestUntrusted ? await lookupThumbnail(ingredient.thumbnail, containingManifestLabel) : await loadThumbnail(undefined, undefined);