diff --git a/package.json b/package.json index 333d3f24d..d8abc4169 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "dependencies": { "@adobe/spectrum-css-workflow-icons": "^5.0.0", "@ccx-public/ingest": "file:etc/packages/ingest-4.3.0.tgz", - "@contentauth/c2pa-web": "^0.7.1", + "@contentauth/c2pa-web": "^0.11.0", "@intl/adobe-locales": "file:etc/packages/adobe-locales-2.35.2.tgz", "@mapbox/mapbox-sdk": "^0.13.7", "@netlify/functions": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 743ac479d..3675795d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: file:etc/packages/ingest-4.3.0.tgz version: file:etc/packages/ingest-4.3.0.tgz '@contentauth/c2pa-web': - specifier: ^0.7.1 - version: 0.7.1 + specifier: ^0.11.0 + version: 0.11.0 '@intl/adobe-locales': specifier: file:etc/packages/adobe-locales-2.35.2.tgz version: file:etc/packages/adobe-locales-2.35.2.tgz @@ -1265,14 +1265,14 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@contentauth/c2pa-types@0.4.3': - resolution: {integrity: sha512-uJJPpv8kS/wLaokb4lPGycyK9yf663H4Db/QDkdWuxxVM8d0UR4ZaIrJXWBeYO9M8gqH8JYJA/WBRkchboJWSQ==} + '@contentauth/c2pa-types@0.6.0': + resolution: {integrity: sha512-b9xPv9seNw0lY2CJtz4TeYl5Z3w5+ZHyhZtgG3M0jj7chtvY0C60LuruByxM8H/7tAiuyEp8Hhrx08pQZ/Fpyg==} - '@contentauth/c2pa-wasm@0.5.1': - resolution: {integrity: sha512-1ka6GRXkkCnGShUd5F5YiAXRXSa+IXPxMIvS6Lm1Epk06KqT/4WWFiDgKaM4ZK7Sxez8eq5QMnrG2rtW7ZYwWw==} + '@contentauth/c2pa-wasm@0.8.0': + resolution: {integrity: sha512-QiTx7UIy0YZ2Os3zB9msT4bLqaKE3VnuTL28Io3L/4LIf5t2648iVKBOMhAFt+RMapNAdt4sE1A/Jsw3RjJRKg==} - '@contentauth/c2pa-web@0.7.1': - resolution: {integrity: sha512-fZTpIDw+DnL+saDp1+PUnq6dhx4EAIBr1Z0hVCf8eHo4Phk6Ptbjnms9R4jU2AnftjwJ1ArQpCW6q3QjI1+ckg==} + '@contentauth/c2pa-web@0.11.0': + resolution: {integrity: sha512-8DBV28iuRqvzrTEDZ7OvMwZ3a9AeKp3lcAinfIeHJ96fa1uUDJ8dC4kAlN/yjCljs0WSeucbwWeBwO4loEkaIA==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -1812,92 +1812,78 @@ packages: resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.0': resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.0': resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.0': resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.0': resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.0': resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.0': resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.3': resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.3': resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.3': resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.3': resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.3': resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.3': resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.3': resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.3': resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==} @@ -3364,49 +3350,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -10558,14 +10536,14 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@contentauth/c2pa-types@0.4.3': {} + '@contentauth/c2pa-types@0.6.0': {} - '@contentauth/c2pa-wasm@0.5.1': {} + '@contentauth/c2pa-wasm@0.8.0': {} - '@contentauth/c2pa-web@0.7.1': + '@contentauth/c2pa-web@0.11.0': dependencies: - '@contentauth/c2pa-types': 0.4.3 - '@contentauth/c2pa-wasm': 0.5.1 + '@contentauth/c2pa-types': 0.6.0 + '@contentauth/c2pa-wasm': 0.8.0 highgain: 0.1.0 ts-deepmerge: 7.0.3 diff --git a/src/lib/asset.ts b/src/lib/asset.ts index 103c0c6dd..4033553ef 100644 --- a/src/lib/asset.ts +++ b/src/lib/asset.ts @@ -43,7 +43,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'; @@ -132,9 +137,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)[] = []; @@ -177,6 +184,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); @@ -231,14 +258,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 && @@ -248,7 +272,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) } ); } @@ -263,6 +287,7 @@ export async function resultToAssetMap({ manifestStore, runtimeValidationStatuses, id, + activeManifestLabel, ), manifestData: await getManifestData(manifest, rootValidationResult), dataType: null, @@ -284,17 +309,34 @@ export async function resultToAssetMap({ manifestStore: ManifestStore, runtimeValidationStatuses: ManifestLabelValidationStatusMap, id: string, + containingManifestLabel: string, ): Promise { const ingredientManifestLabel = ingredient.active_manifest || (ingredient as ExtendedIngredient).activeManifest; 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, - ); + /** + * 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 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) + : !containingManifestUntrusted + ? await lookupThumbnail(ingredient.thumbnail, containingManifestLabel) + : await loadThumbnail(undefined, undefined); const activeManifestValidationResults = (ingredient.validation_results?.activeManifest || (ingredient as ExtendedIngredient).validationResults?.activeManifest) ?? undefined; @@ -317,12 +359,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), @@ -469,6 +512,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}`; @@ -478,6 +522,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..78de569a6 --- /dev/null +++ b/src/lib/resolveThumbnails.ts @@ -0,0 +1,66 @@ +// Copyright 2021-2024 Adobe, Copyright 2026 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();