16 ) { throw new \RuntimeException( 'A bounded public trust-key map is required.' ); } foreach ( $keys as $id => $encoded ) { if ( ! is_string( $id ) || ! preg_match( '/^[A-Za-z0-9._-]{1,64}$/D', $id ) || ! is_string( $encoded ) || 32 !== strlen( (string) base64_decode( $encoded, true ) ) ) { throw new \RuntimeException( 'Invalid public trust key.' ); } } return $keys; } public static function fields( mixed $value, array $fields ): void { if ( ! is_array( $value ) || count( $value ) !== count( $fields ) || array_diff( $fields, array_keys( $value ) ) ) { throw new \RuntimeException( 'Unsupported trust fields.' ); } } public static function verify( string $envelope, array $roots, string $publisher, int $now ): array { self::keys( $roots ); if ( strlen( $envelope ) > self::MAX_BYTES || ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { throw new \RuntimeException( 'Trust verification is unavailable or exceeds its byte budget.' ); } $outer = json_decode( $envelope, true, 8, JSON_THROW_ON_ERROR ); self::fields( $outer, array( 'schema_version', 'key_id', 'payload', 'signature' ) ); if ( 1 !== $outer['schema_version'] || ! is_string( $outer['key_id'] ) || ! isset( $roots[ $outer['key_id'] ] ) || ! is_string( $outer['payload'] ) || ! is_string( $outer['signature'] ) ) { throw new \RuntimeException( 'Untrusted root signature.' ); } $bytes = base64_decode( $outer['payload'], true ); $signature = base64_decode( $outer['signature'], true ); if ( false === $bytes || strlen( $bytes ) > 32768 || false === $signature || 64 !== strlen( $signature ) || ! sodium_crypto_sign_verify_detached( $signature, $bytes, base64_decode( $roots[ $outer['key_id'] ], true ) ) ) { throw new \RuntimeException( 'Trust signature rejected.' ); } $data = json_decode( $bytes, true, 8, JSON_THROW_ON_ERROR ); self::fields( $data, array( 'schema_version', 'kind', 'publisher', 'sequence', 'published_at', 'expires_at', 'minimum_bundle_sequence', 'keys', 'revoked_key_ids' ) ); if ( 1 !== $data['schema_version'] || 'antivirus-ai-rule-trust' !== $data['kind'] || $publisher !== $data['publisher'] || ! is_int( $data['sequence'] ) || $data['sequence'] < 1 || ! is_int( $data['minimum_bundle_sequence'] ) || $data['minimum_bundle_sequence'] < 1 || ! is_int( $data['published_at'] ) || ! is_int( $data['expires_at'] ) || $data['published_at'] < 1 || $data['published_at'] > $now + 300 || $data['expires_at'] <= $now || $data['expires_at'] <= $data['published_at'] || $data['expires_at'] - $data['published_at'] > 30 * 86400 || ! is_array( $data['revoked_key_ids'] ) || ! array_is_list( $data['revoked_key_ids'] ) || count( $data['revoked_key_ids'] ) > 64 ) { throw new \RuntimeException( 'Trust scope, sequence or freshness rejected.' ); } self::keys( $data['keys'] ); $revoked = array(); foreach ( $data['revoked_key_ids'] as $id ) { if ( ! is_string( $id ) || ! preg_match( '/^[A-Za-z0-9._-]{1,64}$/D', $id ) || isset( $revoked[ $id ] ) || isset( $data['keys'][ $id ] ) ) { throw new \RuntimeException( 'Invalid trust revocation.' ); } $revoked[ $id ] = true; } foreach ( $data['keys'] as $key ) { if ( in_array( $key, $roots, true ) ) { throw new \RuntimeException( 'Offline root keys must be separate from online publishing keys.' ); } } $data['digest'] = hash( 'sha256', $bytes ); return $data; } }