= 12 && 'RIFF' === substr( $head, 0, 4 ) && 'WEBP' === substr( $head, 8, 4 ) ) { return 'webp'; } return null; } /** * Locates the manifest payload for the given format. * * Returns an array describing where the JUMBF manifest store lives inside * the source file: * * - `container`: short label (e.g. 'APP11/JUMBF', 'PNG/caBX', 'WebP/C2PA'). * - `format`: 'jpeg'|'png'|'webp'. * - `segments`: list of [offset, length] byte ranges that, concatenated * in order, yield the raw manifest store bytes. * - `total_length`: sum of segment lengths. * * @since 1.5.0 * * @param string $path Absolute path. * @param string $format Format string returned by detect_format(). * @return array|null Location descriptor or null. */ public function find_manifest_location( string $path, string $format ): ?array { if ( ! is_file( $path ) || ! is_readable( $path ) ) { return null; } $fh = fopen( $path, 'rb' ); if ( false === $fh ) { return null; } try { switch ( $format ) { case 'jpeg': return $this->find_jpeg( $fh ); case 'png': return $this->find_png( $fh ); case 'webp': return $this->find_webp( $fh ); default: return null; } } catch ( \Throwable $e ) { return null; } finally { fclose( $fh ); } } /** * Walks a JPEG file looking for contiguous APP11 / JUMBF segments tagged * with the C2PA box type. * * Per ISO 19566-5 / C2PA 2.x, a JUMBF payload that exceeds the 64 KiB JPEG * marker payload limit is split across multiple APP11 segments sharing the * same `Box Instance Number`. We collect the inner JUMBF bytes from each * such segment and concatenate them. * * @since 1.5.0 * * @param resource $fh Open file handle. * @return array|null */ private function find_jpeg( $fh ): ?array { $soi = (string) fread( $fh, 2 ); if ( "\xFF\xD8" !== $soi ) { return null; } $segments = array(); $total_length = 0; $count = 0; $approved_instances = array(); while ( $count < self::JPEG_MAX_SEGMENTS ) { ++$count; $marker = $this->read_jpeg_marker( $fh ); if ( null === $marker ) { break; } // SOS (start of scan): everything after this is entropy-coded; stop. if ( 0xDA === $marker ) { break; } // Standalone markers (RST*, SOI, EOI, TEM) carry no payload. if ( 0xD0 === ( $marker & 0xF8 ) || 0xD8 === $marker || 0xD9 === $marker || 0x01 === $marker ) { if ( 0xD9 === $marker ) { break; } continue; } $length_bytes = (string) fread( $fh, 2 ); if ( strlen( $length_bytes ) < 2 ) { break; } $unpacked = unpack( 'n', $length_bytes ); if ( false === $unpacked ) { break; } $payload_length = (int) $unpacked[1] - 2; if ( $payload_length < 0 ) { break; } $payload_offset = (int) ftell( $fh ); if ( 0xEB === $marker ) { $slice = $this->extract_jpeg_app11_jumbf_slice( $fh, $payload_length, $payload_offset, $approved_instances ); if ( null !== $slice ) { $segments[] = $slice; $total_length += $slice[1]; } elseif ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { break; } continue; } if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { break; } } if ( empty( $segments ) || count( $segments ) > self::JPEG_APP11_MAX_SEGMENTS ) { return null; } return array( 'format' => 'jpeg', 'container' => 'APP11/JUMBF', 'segments' => $segments, 'total_length' => $total_length, ); } /** * Reads the next JPEG marker byte, skipping any 0xFF padding. * * @since 1.5.0 * * @param resource $fh File handle. * @return int|null Marker byte (without the leading 0xFF) or null on EOF. */ private function read_jpeg_marker( $fh ): ?int { $byte = fread( $fh, 1 ); if ( false === $byte || '' === $byte ) { return null; } if ( "\xFF" !== $byte ) { return null; } while ( true ) { $next = fread( $fh, 1 ); if ( false === $next || '' === $next ) { return null; } if ( "\xFF" === $next ) { continue; } $ord = ord( $next ); if ( 0x00 === $ord ) { continue; } return $ord; } } /** * Extracts the JUMBF payload bytes from a single JPEG APP11 segment if it * carries C2PA data. * * The APP11 segment layout (per ISO 19566-5 Annex B), confirmed against * real C2PA-signed assets: * * 2 bytes Common Identifier ("JP") * 2 bytes Box Instance Number (uint16 BE) -- En * 4 bytes Packet Sequence Number (uint32 BE) -- Z; 1 = first, 2+ = continuation * * First segment (Z === 1) -- JUMBF superbox starts immediately at payload +8: * 4 bytes LBox (total size of this JUMBF superbox, uint32 BE) * 4 bytes TBox ("jumb") * 4 bytes inner LBox * 4 bytes inner TBox ("jumd") * 16 bytes C2PA type UUID (63 32 70 61 00 11 00 10 80 00 00 AA 00 38 9B 71) * ... label and nested JUMBF boxes * * Continuation segment (Z > 1) -- box header repeated at payload +8: * 4 bytes LBox (same declared total size) * 4 bytes TBox ("jumb") * ... continuation payload (starts at payload +16) * * We collect [offset, length] slices that, when concatenated, yield the full * JUMBF store: [payload_offset + 8, inner_length] for the first segment and * [payload_offset + 16, inner_length] for each continuation. * * @since 1.5.0 * * @param resource $fh File handle, positioned at the segment payload. * @param int $payload_length Length of this segment's payload, in bytes. * @param int $payload_offset Absolute offset of the segment payload. * @param array $approved_instances Box Instance Numbers already classified as C2PA. Updated by reference. * @return array{0:int,1:int}|null [offset, length] of the JUMBF slice, or null. */ private function extract_jpeg_app11_jumbf_slice( $fh, int $payload_length, int $payload_offset, array &$approved_instances ): ?array { // Minimum: CI(2) + En(2) + Z(4) = 8 bytes. if ( $payload_length < 8 ) { return null; } $header = (string) fread( $fh, 8 ); if ( strlen( $header ) < 8 ) { return null; } if ( 'JP' !== substr( $header, 0, 2 ) ) { if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } return null; } $instance_unpack = unpack( 'n', substr( $header, 2, 2 ) ); if ( false === $instance_unpack ) { return null; } $instance = (int) $instance_unpack[1]; $z_unpack = unpack( 'N', substr( $header, 4, 4 ) ); if ( false === $z_unpack ) { return null; } $z = (int) $z_unpack[1]; if ( 0 === $z ) { if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } return null; } if ( $z > 1 ) { // Continuation segment: skip the repeated LBox(4)+TBox(4) at payload +8. // Minimum: 8-byte prefix + 4 (LBox) + 4 (TBox) = 16 bytes. if ( $payload_length < 16 || ! isset( $approved_instances[ $instance ] ) ) { if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } return null; } $inner_length = $payload_length - 16; if ( $inner_length <= 0 ) { if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } return null; } if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } return array( $payload_offset + 16, $inner_length ); } // Z === 1: first segment. Validate the C2PA JUMBF superbox header. // We need at least 32 bytes of inner content: // LBox(4) + TBox("jumb")(4) + inner_LBox(4) + inner_TBox("jumd")(4) + UUID(16). $inner_length = $payload_length - 8; if ( $inner_length < 32 ) { if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } return null; } $peek = (string) fread( $fh, 32 ); if ( strlen( $peek ) < 32 ) { if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } return null; } // Offset 4 within the slice: TBox of the JUMBF superbox must be "jumb". if ( 'jumb' !== substr( $peek, 4, 4 ) ) { if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } return null; } // Offset 12 within the slice: TBox of the inner UUID box must be "jumd". if ( 'jumd' !== substr( $peek, 12, 4 ) ) { if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } return null; } // Offset 16 within the slice: C2PA type UUID (16 bytes). if ( self::C2PA_BOX_UUID !== substr( $peek, 16, 16 ) ) { if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } return null; } if ( -1 === fseek( $fh, $payload_offset + $payload_length, SEEK_SET ) ) { return null; } $approved_instances[ $instance ] = true; return array( $payload_offset + 8, $inner_length ); } /** * Walks a PNG file looking for a `caBX` chunk (C2PA storage chunk per * C2PA 2.x §11.5). * * @since 1.5.0 * * @param resource $fh File handle positioned at the start of the file. * @return array|null */ private function find_png( $fh ): ?array { if ( -1 === fseek( $fh, 8, SEEK_SET ) ) { return null; } $count = 0; while ( $count < self::PNG_MAX_CHUNKS ) { ++$count; $header = (string) fread( $fh, 8 ); if ( strlen( $header ) < 8 ) { break; } $unpacked = unpack( 'Nlength/a4type', $header ); if ( false === $unpacked ) { break; } $length = (int) $unpacked['length']; $type = (string) $unpacked['type']; if ( $length < 0 || $length > self::png_max_chunk_size() ) { break; } $data_offset = (int) ftell( $fh ); if ( 'caBX' === $type ) { if ( $length <= 0 ) { break; } return array( 'format' => 'png', 'container' => 'PNG/caBX', 'segments' => array( array( $data_offset, $length ) ), 'total_length' => $length, ); } if ( 'IEND' === $type ) { break; } // Skip data + 4-byte CRC. if ( -1 === fseek( $fh, $data_offset + $length + 4, SEEK_SET ) ) { break; } } return null; } /** * Maximum allowable PNG chunk data size. The PNG spec caps chunk length * at 2^31 - 1 bytes; we apply a saner ceiling. * * @since 1.5.0 * * @return int */ private static function png_max_chunk_size(): int { return 64 * 1024 * 1024; } /** * Walks a WebP RIFF container looking for a top-level `C2PA` chunk * (C2PA 2.x §11.6). * * @since 1.5.0 * * @param resource $fh File handle. * @return array|null */ private function find_webp( $fh ): ?array { $riff = (string) fread( $fh, 12 ); if ( strlen( $riff ) < 12 ) { return null; } if ( 'RIFF' !== substr( $riff, 0, 4 ) || 'WEBP' !== substr( $riff, 8, 4 ) ) { return null; } $count = 0; while ( $count < self::WEBP_MAX_CHUNKS ) { ++$count; $header = (string) fread( $fh, 8 ); if ( strlen( $header ) < 8 ) { break; } $type = substr( $header, 0, 4 ); $unpacked = unpack( 'V', substr( $header, 4, 4 ) ); if ( false === $unpacked ) { break; } $length = (int) $unpacked[1]; if ( $length < 0 ) { break; } $data_offset = (int) ftell( $fh ); if ( 'C2PA' === $type ) { return array( 'format' => 'webp', 'container' => 'WebP/C2PA', 'segments' => array( array( $data_offset, $length ) ), 'total_length' => $length, ); } $padded = $data_offset + $length + ( $length % 2 ); if ( -1 === fseek( $fh, $padded, SEEK_SET ) ) { break; } } return null; } }