diff --git a/README.md b/README.md index f021abb..1e7105a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A web application for validating C2PA (Coalition for Content Provenance and Auth - **Client-Side Processing**: Uses the official C2PA SDK compiled to WebAssembly for fast, private processing - **Official C2PA Trust List**: Validates signatures against the official [C2PA Conformance Trust List](https://c2pa.org/conformance) - **Interim Trust List (ITL)**: Automatically detects and validates signatures against the ITL with distinct visual indicators +- **Server-Side OCSP Revocation Checking**: When a signing certificate's OCSP responder is unreachable from the browser (typically because the URL is HTTP and the tool is served over HTTPS), the tool automatically extracts the certificate chain client-side and checks revocation status via a server-side proxy — no file content is ever sent to the server - **Test Certificate Mode**: Enable test mode to load the C2PA Conformance Test Root, download the test signing cert (ZIP), and add custom test certificates (session-only, clearly marked) - **Version Tracking**: Every report includes git commit SHA and date for reproducibility ([details](VERSION_TRACKING.md)) - **Modern Tailwind CSS UI**: Clean, responsive design matching verify.contentauthenticity.org @@ -79,6 +80,9 @@ See [DEPLOYMENT.md](./DEPLOYMENT.md) for details on the build process and previe ``` conformance-tool/ +├── netlify/ +│ └── functions/ +│ └── ocsp-proxy.ts # Server-side OCSP proxy with in-memory cache ├── src/ │ ├── lib/ │ │ ├── FileUpload.svelte # Drag-and-drop file upload component @@ -88,6 +92,7 @@ conformance-tool/ │ │ ├── c2pa.ts # TypeScript interface to @contentauth/c2pa-web │ │ ├── crjson.ts # crJSON types and helpers │ │ ├── generateSummary.ts # Report summary generation +│ │ ├── ocspExtract.ts # Client-side cert extraction from JUMBF/COSE │ │ ├── trustListTest.ts # Trust list (C2PA vs ITL) detection │ │ └── types.ts # Shared types │ ├── App.svelte # Main application component @@ -97,6 +102,7 @@ conformance-tool/ │ ├── generate-version.js # Build-time git version (see VERSION_TRACKING.md) │ └── build-local-wasm.mjs # Optional local WASM build (see scripts/README.md) ├── index.html +├── netlify.toml # Netlify build config and function settings ├── package.json └── vite.config.ts ``` @@ -143,8 +149,9 @@ The tool supports any file format that can contain C2PA manifests: 3. **Trust Validation**: Signatures are validated against the official C2PA trust lists: - **C2PA-TRUST-LIST.pem** - Approved signing certificates for conformant products - **C2PA-TSA-TRUST-LIST.pem** - Trusted Time Stamp Authorities -4. **Report Generation**: C2PA manifest data is extracted, validated, and formatted -5. **Display**: Results are shown in both human-readable and JSON formats with trust status +4. **OCSP Check**: If the SDK cannot reach the OCSP responder from the browser (see [OCSP Revocation Checking](#ocsp-revocation-checking) below), a server-side fallback check is triggered automatically +5. **Report Generation**: C2PA manifest data is extracted, validated, and formatted +6. **Display**: Results are shown in both human-readable and JSON formats with trust status ## Trust Verification & Conformance @@ -159,10 +166,54 @@ This tool uses the **official C2PA Conformance Trust List** to validate digital Learn more: [C2PA Conformance Program](https://c2pa.org/conformance) +## OCSP Revocation Checking + +C2PA signing certificates often list an OCSP (Online Certificate Status Protocol) responder URL in their Authority Information Access extension. The C2PA SDK checks this responder during validation to confirm the signing certificate has not been revoked. + +### Why the browser sometimes can't check OCSP + +When the tool is served over HTTPS (as it is in production), many OCSP responders are unreachable because their URLs use plain HTTP. Browsers block such mixed-content requests, and most OCSP endpoints also reject browser-originated requests due to [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) policy. When this happens, the SDK reports `signingCredential.ocsp.inaccessible` and cannot determine revocation status. + +### Server-side fallback + +When the SDK reports OCSP as inaccessible, the tool automatically performs a server-side check: + +1. **Client-side extraction** (`src/lib/ocspExtract.ts`): The signing certificate chain is extracted directly from the file's embedded JUMBF/COSE signature bytes — no file content is sent anywhere. Specifically: + - For JPEG files: APP11 segments are reassembled into the JUMBF stream + - For all other formats: JUMBF boxes are scanned from the raw file bytes + - The `c2pa.signature` box is located, its COSE_Sign1 envelope is CBOR-decoded, and the `x5chain` (key 33) from the protected header yields the DER-encoded certificate chain + - The leaf cert's AIA extension provides the OCSP responder URL + +2. **Server proxy** (`netlify/functions/ocsp-proxy.ts`): A Netlify Function receives `{ responderUrl, certDerB64, issuerDerB64 }`, builds a proper OCSP request (SHA-1 CertID per RFC 6960), and forwards it to the OCSP responder. The result is cached in-process to avoid redundant upstream queries. + +3. **Reactive update**: The initial report is displayed immediately. When the server check completes, the UI reactively updates the OCSP status in the Signature Info section. + +### Cache TTL + +The function cache is ephemeral (resets on cold start) and controlled by the `OCSP_CACHE_TTL_SECONDS` environment variable in `netlify.toml`: + +| Context | Default TTL | +|---|---| +| Production | 3600 s (1 hour) | +| Deploy preview | 300 s (5 minutes) | + +To override, set `OCSP_CACHE_TTL_SECONDS` in the Netlify environment variables for the relevant deploy context. + +### Local development + +The Netlify Function is not available when running `npm run dev` (Vite only). To test the full OCSP flow locally, use: + +```bash +npm install -g netlify-cli +netlify dev +``` + +`netlify dev` runs both the Vite dev server and the Netlify Functions runtime, making the proxy available at `/.netlify/functions/ocsp-proxy`. + ## Privacy & Security -- **Client-Side Only**: All file processing happens in your browser -- **No Server Upload**: Files never leave your machine +- **File Processing**: All C2PA manifest parsing happens in your browser via WebAssembly — files never leave your machine +- **OCSP Checking**: When a server-side OCSP check is triggered, only the signing certificate's DER bytes and the OCSP responder URL are sent to the Netlify Function proxy. No file content, metadata, or user information is transmitted - **No Data Collection**: No tracking or analytics - **Trust List Updates**: Trust lists are fetched directly from the official C2PA repository when processing files @@ -195,7 +246,8 @@ Clear your browser cache and reload the page. The WASM module is loaded from the - **Vite** - Build tool and dev server - **@contentauth/c2pa-web** - Official C2PA JavaScript/WASM SDK from Content Authenticity Initiative - **highlight.js** - Syntax highlighting for raw JSON in reports -- **@peculiar/x509** - Certificate parsing (e.g. in CertificateManager) +- **@peculiar/x509** - Certificate parsing (CertificateManager, OCSP AIA extension extraction) +- **cborg** - CBOR decoding for COSE_Sign1 protected headers during OCSP cert extraction ## License diff --git a/netlify.toml b/netlify.toml index 139990e..c1d8893 100644 --- a/netlify.toml +++ b/netlify.toml @@ -4,3 +4,12 @@ [build.environment] NODE_VERSION = "20" + +[functions] + directory = "netlify/functions" + +[context.production.environment] + OCSP_CACHE_TTL_SECONDS = "3600" + +[context.deploy-preview.environment] + OCSP_CACHE_TTL_SECONDS = "300" diff --git a/netlify/functions/ocsp-proxy.ts b/netlify/functions/ocsp-proxy.ts new file mode 100644 index 0000000..91cdcda --- /dev/null +++ b/netlify/functions/ocsp-proxy.ts @@ -0,0 +1,327 @@ +/** + * Netlify Function: OCSP proxy with in-memory cache. + * + * Accepts POST { responderUrl, certDerB64, issuerDerB64 }. + * Builds an OCSP request, forwards it to the given OCSP responder, + * parses the response, and returns { status, nextUpdate }. + * Results are cached in-process; the cache resets on cold start. + */ + +import { createHash } from 'node:crypto' + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface HandlerEvent { + httpMethod: string + body: string | null + headers: Record +} + +interface HandlerResponse { + statusCode: number + headers?: Record + body: string +} + +export interface OcspServerResult { + status: 'good' | 'revoked' | 'unknown' | 'error' + nextUpdate?: string + error?: string +} + +// ── In-memory cache ─────────────────────────────────────────────────────────── + +interface CacheEntry extends OcspServerResult { + cachedAt: number +} + +const cache = new Map() +const TTL_MS = parseInt(process.env.OCSP_CACHE_TTL_SECONDS ?? '3600', 10) * 1000 + +// ── Handler ─────────────────────────────────────────────────────────────────── + +export const handler = async (event: HandlerEvent): Promise => { + if (event.httpMethod !== 'POST') { + return { statusCode: 405, body: 'Method Not Allowed' } + } + + let certDerB64: string, issuerDerB64: string, responderUrl: string + try { + const body = JSON.parse(event.body ?? '{}') as Record + certDerB64 = body.certDerB64 as string + issuerDerB64 = body.issuerDerB64 as string + responderUrl = body.responderUrl as string + if (!certDerB64 || !issuerDerB64 || !responderUrl) throw new Error('missing fields') + } catch (e) { + return { statusCode: 400, body: JSON.stringify({ status: 'error', error: 'Invalid request body' }) } + } + + // Sanitize: only allow http/https OCSP URLs + if (!/^https?:\/\//i.test(responderUrl)) { + return { statusCode: 400, body: JSON.stringify({ status: 'error', error: 'Invalid responder URL' }) } + } + + const certDer = Buffer.from(certDerB64, 'base64') + const issuerDer = Buffer.from(issuerDerB64, 'base64') + + // Cache key: responder URL + SHA-1 of the cert DER (identifies the specific cert) + const cacheKey = `${responderUrl}:${createHash('sha1').update(certDer).digest('hex')}` + + const cached = cache.get(cacheKey) + if (cached && Date.now() - cached.cachedAt < TTL_MS) { + const { cachedAt: _, ...result } = cached + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(result), + } + } + + const result = await queryOcsp(responderUrl, certDer, issuerDer) + cache.set(cacheKey, { ...result, cachedAt: Date.now() }) + + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(result), + } +} + +// ── OCSP logic ──────────────────────────────────────────────────────────────── + +async function queryOcsp( + responderUrl: string, + certDer: Buffer, + issuerDer: Buffer, +): Promise { + try { + const issuerSubject = extractSubjectName(issuerDer) + const issuerKeyBits = extractPublicKeyBitString(issuerDer) + const serial = extractSerialNumber(certDer) + + if (!issuerSubject || !issuerKeyBits || !serial) { + return { status: 'error', error: 'Could not parse certificate fields' } + } + + const issuerNameHash = createHash('sha1').update(issuerSubject).digest() + const issuerKeyHash = createHash('sha1').update(issuerKeyBits).digest() + + const ocspReq = buildOcspRequest(issuerNameHash, issuerKeyHash, serial) + + const response = await fetch(responderUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/ocsp-request' }, + body: ocspReq, + signal: AbortSignal.timeout(10_000), + }) + + if (!response.ok) { + return { status: 'error', error: `OCSP responder returned HTTP ${response.status}` } + } + + const responseBytes = Buffer.from(await response.arrayBuffer()) + return parseOcspResponse(responseBytes) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return { status: 'error', error: msg } + } +} + +// ── DER building ────────────────────────────────────────────────────────────── + +// SHA-1 AlgorithmIdentifier: SEQUENCE { OID 1.3.14.3.2.26, NULL } +const SHA1_ALG_ID = Buffer.from([0x30, 0x09, 0x06, 0x05, 0x2B, 0x0E, 0x03, 0x02, 0x1A, 0x05, 0x00]) + +function derLen(n: number): Buffer { + if (n < 0x80) return Buffer.from([n]) + if (n < 0x100) return Buffer.from([0x81, n]) + return Buffer.from([0x82, (n >> 8) & 0xFF, n & 0xFF]) +} + +function derSeq(...parts: Buffer[]): Buffer { + const body = Buffer.concat(parts) + return Buffer.concat([Buffer.from([0x30]), derLen(body.length), body]) +} + +function derOctetStr(data: Buffer): Buffer { + return Buffer.concat([Buffer.from([0x04]), derLen(data.length), data]) +} + +function derInt(bytes: Buffer): Buffer { + // Strip leading zero bytes but preserve sign + let start = 0 + while (start < bytes.length - 1 && bytes[start] === 0) start++ + const trimmed = bytes.subarray(start) + const needsSign = (trimmed[0] & 0x80) !== 0 + const value = needsSign ? Buffer.concat([Buffer.from([0x00]), trimmed]) : trimmed + return Buffer.concat([Buffer.from([0x02]), derLen(value.length), value]) +} + +function buildOcspRequest( + issuerNameHash: Buffer, + issuerKeyHash: Buffer, + serialNumber: Buffer, +): Buffer { + const certId = derSeq(SHA1_ALG_ID, derOctetStr(issuerNameHash), derOctetStr(issuerKeyHash), derInt(serialNumber)) + return derSeq(derSeq(derSeq(derSeq(certId)))) // OCSPRequest { TBSRequest { requestList { Request { CertID } } } } +} + +// ── DER parsing ─────────────────────────────────────────────────────────────── + +interface DerNode { tag: number; len: number; valStart: number; total: number } + +function readDer(b: Buffer, pos: number): DerNode { + const tag = b[pos] + let lp = pos + 1 + let len: number + if (b[lp] < 0x80) { + len = b[lp++] + } else { + const nb = b[lp++] & 0x7F + len = 0 + for (let i = 0; i < nb; i++) len = (len << 8) | b[lp++] + } + return { tag, len, valStart: lp, total: lp - pos + len } +} + +function extractSubjectName(certDer: Buffer): Buffer | null { + try { + const cert = readDer(certDer, 0) // Certificate SEQUENCE + const tbs = readDer(certDer, cert.valStart) // TBSCertificate SEQUENCE + let pos = tbs.valStart + + if (certDer[pos] === 0xA0) pos += readDer(certDer, pos).total // version [0] + pos += readDer(certDer, pos).total // serialNumber + pos += readDer(certDer, pos).total // signature AlgorithmIdentifier + pos += readDer(certDer, pos).total // issuer Name (skip issuer, keep for subject below) + pos += readDer(certDer, pos).total // validity + const sub = readDer(certDer, pos) // subject Name + return certDer.subarray(pos, pos + sub.total) + } catch { return null } +} + +function extractPublicKeyBitString(certDer: Buffer): Buffer | null { + try { + const cert = readDer(certDer, 0) + const tbs = readDer(certDer, cert.valStart) + let pos = tbs.valStart + + if (certDer[pos] === 0xA0) pos += readDer(certDer, pos).total + pos += readDer(certDer, pos).total // serialNumber + pos += readDer(certDer, pos).total // signature + pos += readDer(certDer, pos).total // issuer + pos += readDer(certDer, pos).total // validity + pos += readDer(certDer, pos).total // subject + + // SubjectPublicKeyInfo SEQUENCE + const spki = readDer(certDer, pos) + let spkiPos = spki.valStart + spkiPos += readDer(certDer, spkiPos).total // AlgorithmIdentifier + + // BIT STRING: first byte is unused-bits count + const bs = readDer(certDer, spkiPos) + return certDer.subarray(bs.valStart, bs.valStart + bs.len) + } catch { return null } +} + +function extractSerialNumber(certDer: Buffer): Buffer | null { + try { + const cert = readDer(certDer, 0) + const tbs = readDer(certDer, cert.valStart) + let pos = tbs.valStart + if (certDer[pos] === 0xA0) pos += readDer(certDer, pos).total + const serial = readDer(certDer, pos) + return certDer.subarray(serial.valStart, serial.valStart + serial.len) + } catch { return null } +} + +// ── OCSP response parsing ───────────────────────────────────────────────────── + +function parseOcspResponse(bytes: Buffer): OcspServerResult { + try { + // OCSPResponse SEQUENCE + const ocspResp = readDer(bytes, 0) + if (ocspResp.tag !== 0x30) return { status: 'error', error: 'Not a SEQUENCE' } + + let pos = ocspResp.valStart + + // responseStatus ENUMERATED (0 = successful) + const statusNode = readDer(bytes, pos) + if (statusNode.tag !== 0x0A || bytes[statusNode.valStart] !== 0) { + return { status: 'error', error: `OCSP response status: ${bytes[statusNode.valStart]}` } + } + pos += statusNode.total + + // responseBytes [0] EXPLICIT OPTIONAL + if (bytes[pos] !== 0xA0) return { status: 'error', error: 'No responseBytes' } + const rbCtx = readDer(bytes, pos) + pos = rbCtx.valStart + + // ResponseBytes SEQUENCE + const rbSeq = readDer(bytes, pos) + pos = rbSeq.valStart + + // responseType OID (skip) + pos += readDer(bytes, pos).total + + // response OCTET STRING → BasicOCSPResponse DER + const octetStr = readDer(bytes, pos) + const basic = Buffer.from(bytes.subarray(octetStr.valStart, octetStr.valStart + octetStr.len)) + + return parseBasicOcspResponse(basic) + } catch (e) { + return { status: 'error', error: e instanceof Error ? e.message : String(e) } + } +} + +function parseBasicOcspResponse(basic: Buffer): OcspServerResult { + // BasicOCSPResponse SEQUENCE + const basicSeq = readDer(basic, 0) + let pos = basicSeq.valStart + + // tbsResponseData ResponseData SEQUENCE + const tbsSeq = readDer(basic, pos) + pos = tbsSeq.valStart + + if (basic[pos] === 0xA0) pos += readDer(basic, pos).total // version + pos += readDer(basic, pos).total // responderID + pos += readDer(basic, pos).total // producedAt + + // responses SEQUENCE OF SingleResponse + const responses = readDer(basic, pos) + pos = responses.valStart + + // First SingleResponse SEQUENCE + const singleResp = readDer(basic, pos) + pos = singleResp.valStart + + // certID CertID (skip) + pos += readDer(basic, pos).total + + // certStatus CHOICE: + // good [0] IMPLICIT NULL → 0x80 0x00 + // revoked [1] IMPLICIT ... → 0xA1 ... + // unknown [2] IMPLICIT NULL → 0x82 0x00 + const certStatusTag = basic[pos] + + let status: OcspServerResult['status'] + if (certStatusTag === 0x80) status = 'good' + else if (certStatusTag === 0xA1) status = 'revoked' + else status = 'unknown' + + const certStatusNode = readDer(basic, pos) + pos += certStatusNode.total + + // thisUpdate GeneralizedTime (skip) + pos += readDer(basic, pos).total + + // nextUpdate [0] EXPLICIT OPTIONAL + let nextUpdate: string | undefined + if (pos < singleResp.valStart + singleResp.len && basic[pos] === 0xA0) { + const nuCtx = readDer(basic, pos) + const nuTime = readDer(basic, nuCtx.valStart) + nextUpdate = basic.subarray(nuTime.valStart, nuTime.valStart + nuTime.len).toString('ascii') + } + + return { status, nextUpdate } +} diff --git a/package-lock.json b/package-lock.json index ae21cbe..53d9c49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@adobe/json-formula": "^2.0.0", "@contentauth/c2pa-web": "^0.6.1", "@peculiar/x509": "^1.14.3", + "cborg": "^5.1.1", "highlight.js": "^11.11.1", "yaml": "^2.8.3" }, @@ -2013,6 +2014,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cborg": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-5.1.1.tgz", + "integrity": "sha512-BDbSRIp6XrQXkTc7g+DN0RB9RrDPTUfals2ecWUlt3juPLjbAvy/V72mJcXY0Ehu0Dq/3WpNCOCT68HUTbW+lw==", + "license": "Apache-2.0", + "bin": { + "cborg": "lib/bin.js" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", diff --git a/package.json b/package.json index 398e3cf..39bf6d6 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@adobe/json-formula": "^2.0.0", "@contentauth/c2pa-web": "^0.6.1", "@peculiar/x509": "^1.14.3", + "cborg": "^5.1.1", "highlight.js": "^11.11.1", "yaml": "^2.8.3" } diff --git a/src/App.svelte b/src/App.svelte index 2e51092..2a1aaf2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -9,7 +9,7 @@ import FileUpload from './lib/FileUpload.svelte' import ReportViewer from './lib/ReportViewer.svelte' import CertificateManager from './lib/CertificateManager.svelte' - import { processFile, processSidecarWithAsset, isSidecarFile } from './lib/c2pa' + import { processFile, processSidecarWithAsset, isSidecarFile, checkOcspForReport } from './lib/c2pa' import { testTrustListFetch } from './lib/trustListTest' import type { ConformanceReport } from './lib/types' @@ -155,6 +155,16 @@ await handleFileSelect({ detail: file } as CustomEvent) } + // After the initial report is ready, check OCSP server-side in the background + // and reactively patch the report if a result comes back. + function triggerOcspCheck(file: File, snapshot: ConformanceReport) { + checkOcspForReport(file, snapshot).then(ocspResult => { + if (ocspResult && report === snapshot) { + report = { ...snapshot, ocspServerResult: ocspResult } + } + }).catch(() => { /* silent — OCSP is best-effort */ }) + } + async function handleFileSelect(event: CustomEvent) { const file = event.detail console.log('📄 File selected:', file.name, file.type, file.size, 'bytes') @@ -187,6 +197,7 @@ } report = await processFile(file, testCertificates) + triggerOcspCheck(file, report) processingStatus = 'Building report...' await new Promise(resolve => setTimeout(resolve, 100)) @@ -229,6 +240,7 @@ processingStatus = 'Validating manifest against asset...' report = await processSidecarWithAsset(sidecar, asset, testCertificates) + triggerOcspCheck(asset, report) processingStatus = 'Building report...' await new Promise(resolve => setTimeout(resolve, 100)) @@ -266,8 +278,10 @@ await new Promise(resolve => setTimeout(resolve, 0)) if (sidecarFile) { report = await processSidecarWithAsset(sidecarFile, selectedFile, testCertificates) + triggerOcspCheck(selectedFile, report) } else { report = await processFile(selectedFile, testCertificates) + triggerOcspCheck(selectedFile, report) } console.log('✅ File reprocessed successfully') } catch (err) { diff --git a/src/lib/ReportViewer.svelte b/src/lib/ReportViewer.svelte index 171830b..d3afc63 100644 --- a/src/lib/ReportViewer.svelte +++ b/src/lib/ReportViewer.svelte @@ -990,10 +990,29 @@ No OCSP staple present {:else if ocspStatus === 'inaccessible'} -
- - OCSP server inaccessible -
+ {#if report.ocspServerResult} + {#if report.ocspServerResult.status === 'good'} +
+ + Not revoked (server-side check) +
+ {:else if report.ocspServerResult.status === 'revoked'} +
+ + Certificate revoked (server-side check) +
+ {:else} +
+ + OCSP server inaccessible from browser +
+ {/if} + {:else} +
+ + OCSP server inaccessible — checking via server… +
+ {/if} {/if} {/if} diff --git a/src/lib/c2pa.ts b/src/lib/c2pa.ts index 14a7787..faf57cd 100644 --- a/src/lib/c2pa.ts +++ b/src/lib/c2pa.ts @@ -1,9 +1,10 @@ import { createC2pa } from '@contentauth/c2pa-web' import type { Settings } from '@contentauth/c2pa-web' import { VERSION_INFO } from './version' -import type { ConformanceReport } from './types' +import type { ConformanceReport, OcspServerResult } from './types' import { VALIDATION_STATUS } from './constants' import { isCrJson, legacyToCrJson, getActiveManifestValidationStatus, type CrJson } from './crjson' +import { extractOcspParams } from './ocspExtract' type ReaderHandle = { manifestStore: () => Promise @@ -290,7 +291,15 @@ const MIME_TYPE_MAP: Record = { // `.c2pa` is the standalone manifest-store sidecar format (RFC-style, no embedded asset). // Browsers universally leave its type empty or fall back to application/octet-stream, so // we resolve by extension. +// +// HEIC/HEIF are included here because Windows Chrome cannot identify them without the +// optional HEIF Image Extensions codec pack installed in Windows; file.type arrives as '' +// on most Windows machines, causing the SDK to receive an empty format and throw. const EXTENSION_MIME_MAP: Record = { + 'heic': 'image/heic', + 'heif': 'image/heif', + 'avci': 'image/avci', + 'avcs': 'image/avcs', 'dng': 'image/x-adobe-dng', 'arw': 'image/x-sony-arw', 'cr2': 'image/x-canon-cr2', @@ -724,3 +733,60 @@ export async function getVersion(): Promise { const version = await c2pa.getVersion?.() return version ?? '@contentauth/c2pa-web v0.6.1' } + +/** + * Perform a server-side OCSP check for the file's signing certificate. + * Called when the initial report shows `signingCredential.ocsp.inaccessible`. + * + * Returns null if extraction fails, the function endpoint is unreachable, + * or the file type doesn't support cert extraction. + */ +export async function checkOcspForReport(file: File, report: ConformanceReport): Promise { + // Only run when OCSP was inaccessible from the browser + const activeVr = getActiveManifestValidationStatus(report) + const isInaccessible = [ + ...(activeVr?.failure ?? []), + ...(activeVr?.informational ?? []), + ].some(s => s.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_OCSP_INACCESSIBLE) + + if (!isInaccessible) { + console.log('[OCSP] Server-side check not needed — signingCredential.ocsp.inaccessible not present in report') + return null + } + + console.log('[OCSP] Server-side check needed — signingCredential.ocsp.inaccessible detected; extracting cert chain from file...') + + const params = await extractOcspParams(file) + if (!params) { + console.warn('[OCSP] Could not extract cert chain or OCSP responder URL from file — skipping server-side check') + return null + } + + console.log('[OCSP] Cert chain extracted. Responder URL:', params.responderUrl) + + const endpoint = '/.netlify/functions/ocsp-proxy' + console.log('[OCSP] Sending request to proxy:', endpoint) + + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + signal: AbortSignal.timeout(15_000), + }) + if (!res.ok) { + console.warn('[OCSP] Proxy returned HTTP', res.status, '— skipping') + return null + } + const result = (await res.json()) as OcspServerResult + if (result.status === 'error') { + console.warn('[OCSP] Result: error —', result.error) + } else { + console.log('[OCSP] Result:', result.status, result.nextUpdate ? `(next update: ${result.nextUpdate})` : '') + } + return result + } catch (e) { + console.warn('[OCSP] Request failed:', e) + return null + } +} diff --git a/src/lib/ocspExtract.ts b/src/lib/ocspExtract.ts new file mode 100644 index 0000000..fb0b808 --- /dev/null +++ b/src/lib/ocspExtract.ts @@ -0,0 +1,205 @@ +/** + * Client-side extraction of certificate chain from a C2PA-signed file's COSE signature. + * + * Parses JUMBF boxes (JPEG APP11 or raw scan) to find the c2pa.signature box, + * CBOR-decodes the COSE_Sign1 envelope, and extracts the x5chain (key 33) from + * the protected header. The leaf cert's AIA extension provides the OCSP URL. + */ + +import { decode, Tagged } from 'cborg' +import { X509Certificate, AuthorityInfoAccessExtension } from '@peculiar/x509' + +export interface OcspExtractResult { + responderUrl: string + certDerB64: string + issuerDerB64: string +} + +// COSE protected header key for x5chain (RFC 9360) +const COSE_X5CHAIN = 33 + +export async function extractOcspParams(file: File): Promise { + try { + const fileBytes = new Uint8Array(await file.arrayBuffer()) + + // For JPEG, APP11 segments must be reassembled into a contiguous JUMBF stream. + // All other formats have JUMBF inline in the raw bytes. + const jumbfBytes = isJpeg(fileBytes) ? extractJpegJumbf(fileBytes) ?? fileBytes : fileBytes + + const coseBytes = findCoseInJumbf(jumbfBytes, 0, jumbfBytes.length) + if (!coseBytes) return null + + const certChain = decodeCoseX5chain(coseBytes) + if (!certChain || certChain.length < 2) return null + + const cert = new X509Certificate(certChain[0].buffer as ArrayBuffer) + const aiaExt = cert.getExtension(AuthorityInfoAccessExtension) + const ocspUrls = aiaExt?.ocsp ?? [] + const responderUrl = ocspUrls.find(n => typeof n.value === 'string')?.value + if (typeof responderUrl !== 'string' || !responderUrl.startsWith('http')) return null + + return { + responderUrl, + certDerB64: toBase64(certChain[0]), + issuerDerB64: toBase64(certChain[1]), + } + } catch { + return null + } +} + +// ── JPEG APP11 reassembly ───────────────────────────────────────────────────── + +function isJpeg(bytes: Uint8Array): boolean { + return bytes.length >= 2 && bytes[0] === 0xFF && bytes[1] === 0xD8 +} + +function extractJpegJumbf(bytes: Uint8Array): Uint8Array | null { + const segments = new Map>() + let pos = 2 + + while (pos + 4 <= bytes.length) { + if (bytes[pos] !== 0xFF) break + const marker = bytes[pos + 1] + + if (marker === 0xD9 || marker === 0xDA) break // EOI / SOS + + // RST markers and standalone markers have no payload + if (marker >= 0xD0 && marker <= 0xD8) { pos += 2; continue } + + const segLen = (bytes[pos + 2] << 8) | bytes[pos + 3] // includes 2-byte length field itself + if (segLen < 2 || pos + 2 + segLen > bytes.length) break + + if (marker === 0xEB && // APP11 + bytes[pos + 4] === 0x4A && bytes[pos + 5] === 0x50 && // CI = "JP" + pos + 10 <= bytes.length) { + const en = (bytes[pos + 6] << 8) | bytes[pos + 7] + const z = (bytes[pos + 8] << 8) | bytes[pos + 9] + const data = bytes.slice(pos + 10, pos + 2 + segLen) + if (!segments.has(en)) segments.set(en, []) + segments.get(en)!.push({ z, data }) + } + + pos += 2 + segLen + } + + if (segments.size === 0) return null + + const segs = [...segments.values()][0] + segs.sort((a, b) => a.z - b.z) + const total = segs.reduce((s, seg) => s + seg.data.length, 0) + const out = new Uint8Array(total) + let off = 0 + for (const seg of segs) { out.set(seg.data, off); off += seg.data.length } + return out +} + +// ── JUMBF box walking ───────────────────────────────────────────────────────── + +function readU32BE(b: Uint8Array, p: number): number { + return ((b[p] << 24) | (b[p + 1] << 16) | (b[p + 2] << 8) | b[p + 3]) >>> 0 +} + +/** Returns the COSE_Sign1 bytes from the first c2pa.signature bidb box found. */ +function findCoseInJumbf(bytes: Uint8Array, start: number, end: number): Uint8Array | null { + let pos = start + while (pos + 8 <= end) { + let size = readU32BE(bytes, pos) + const type = String.fromCharCode(bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]) + let hdrSize = 8 + + // Extended 64-bit size (only low 32 bits used for practical file sizes) + if (size === 1 && pos + 16 <= end) { + size = readU32BE(bytes, pos + 12) + hdrSize = 16 + } + + if (size < hdrSize || pos + size > end) break + + if (type === 'jumb') { + const result = searchJumbBox(bytes, pos + hdrSize, pos + size) + if (result) return result + } + + if (size === 0) break + pos += size + } + return null +} + +function searchJumbBox(bytes: Uint8Array, start: number, end: number): Uint8Array | null { + let pos = start + let label: string | null = null + let bidbContent: Uint8Array | null = null + + while (pos + 8 <= end) { + let size = readU32BE(bytes, pos) + const type = String.fromCharCode(bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]) + let hdrSize = 8 + + if (size === 1 && pos + 16 <= end) { size = readU32BE(bytes, pos + 12); hdrSize = 16 } + if (size < hdrSize || pos + size > end) break + + if (type === 'jumd') { + // UUID (16 bytes) + toggle (1 byte) + null-terminated label + const labelStart = pos + hdrSize + 16 + 1 + let labelEnd = labelStart + while (labelEnd < end && bytes[labelEnd] !== 0) labelEnd++ + label = new TextDecoder().decode(bytes.slice(labelStart, labelEnd)) + } else if (type === 'bidb') { + bidbContent = bytes.slice(pos + hdrSize, pos + size) + } else if (type === 'jumb') { + // Recurse into nested manifest container boxes + const nested = searchJumbBox(bytes, pos + hdrSize, pos + size) + if (nested) return nested + } + + if (size === 0) break + pos += size + } + + return label === 'c2pa.signature' && bidbContent ? bidbContent : null +} + +// ── COSE x5chain extraction ─────────────────────────────────────────────────── + +function decodeCoseX5chain(coseBytes: Uint8Array): Uint8Array[] | null { + try { + // COSE_Sign1 may be wrapped in CBOR tag 18; preserve it so decode doesn't throw. + const decoded = decode(coseBytes, { tags: Tagged.preserve(18) }) + const array: unknown = decoded instanceof Tagged ? decoded.value : decoded + + if (!Array.isArray(array) || array.length < 2) return null + + const protectedBstr = array[0] + if (!(protectedBstr instanceof Uint8Array)) return null + + const headerMap = decode(protectedBstr, { useMaps: true }) + let x5chain = (headerMap instanceof Map) ? headerMap.get(COSE_X5CHAIN) : undefined + + // Fall back to unprotected header (some implementations put x5chain there) + if (x5chain === undefined) { + const unprotected = array[1] + if (unprotected instanceof Map) x5chain = unprotected.get(COSE_X5CHAIN) + } + + if (x5chain instanceof Uint8Array) return [x5chain] + if (Array.isArray(x5chain) && x5chain.every(v => v instanceof Uint8Array)) { + return x5chain as Uint8Array[] + } + return null + } catch { + return null + } +} + +// ── Utilities ───────────────────────────────────────────────────────────────── + +function toBase64(bytes: Uint8Array): string { + let binary = '' + const chunk = 8192 + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)) + } + return btoa(binary) +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 0692b3c..da23459 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -15,10 +15,18 @@ export type { CrJsonClaimInfo } from './crjson' +/** OCSP revocation check result from the server-side proxy */ +export interface OcspServerResult { + status: 'good' | 'revoked' | 'unknown' | 'error' + nextUpdate?: string + error?: string +} + /** Report returned by processFile: crJSON (native format) plus conformance-tool metadata */ export interface ConformanceReport extends CrJson { usedITL?: boolean usedTestCerts?: boolean + ocspServerResult?: OcspServerResult _conformanceToolVersion?: { commit: string shortCommit: string