php 给 pdf 签名

引子。说说我们公司吧,以二代测序技术和生物信息学为核心,从事肿瘤个体化精准诊疗和伴随诊断。粗暴一点来讲,卖报告的。报告以PDF文档为载体,纸质文件会加盖公章。

需求:使用证书给PDF签名,防止篡改

签名的这东西,在 https 标配化的今天,已经很普遍了。大概意思,比如我(王大力)发布一个文档,我用我的签名文件(即数字证书)给文档加一个签名,让阅读文档的人知道,哦,这个文档是大力的。一旦别人坏人修改了内容,则大力的签名即消息。因为别人是没有我的签名的,签名不能被模仿。

其中主要的技术环节,主要是有大型的机构纷发、核验签名证书。PDF 阅读软件对签名的检查和提示。最重要的是我必须保护好自己的签名,不能让坏人得到。否则,坏人就可以冒充我发布内容,而让我背锅…

引子。说说我们公司吧,以二代测序技术和生物信息学为核心,从事肿瘤个体化精准诊疗和伴随诊断。粗暴一点来讲,卖报告的。报告以PDF文档为载体,纸质文件会加盖公章。

需求:使用证书给PDF签名,防止篡改

签名的这东西,在 https 标配化的今天,已经很普遍了。大概意思,比如我(王大力)发布一个文档,我用我的签名文件(即数字证书)给文档加一个签名,让阅读文档的人知道,哦,这个文档是大力的。一旦别人坏人修改了内容,则大力的签名即消息。因为别人是没有我的签名的,签名不能被模仿。

其中主要的技术环节,主要是有大型的机构纷发、核验签名证书。PDF 阅读软件对签名的检查和提示。最重要的是我必须保护好自己的签名,不能让坏人得到。否则,坏人就可以冒充我发布内容,而让我背锅…

案例:

貌似离我最近的案例就是车险的电子保单。有一个公章。在 Macos 下使用预览程序打开 PDF 文件,看不到公章。而使用 Adobe Reader 可以看到公章。点击公章,能看到签名信息,以及最重要的,文档是否被修改过。

我们的目标大概也是这样。但到目前为止(技术探索阶段)没有搞明白公章是怎么消失的。所以我们的 PDF 文件,暂时目标就是依靠阅读器,去检查是否被修改过,能显示签名信息即可。

方法论:

公司生成 PDF 文件的工具,是购买的 prince 软件,授权费适中,即来之,则安之。首先想到就是该程序在生成是,把签名加上,万事大吉。当然,这款软件也是牛B,好几年前有人给留言类似的功能,他们就是不加。这里说一个他们的方向:H52Pdf。一个方向上做好了,也是够吃了。

不过在去搞代码前,我花了大半天的时间,在安装各类PDF阅读器,编辑器。通过编辑工具加签名,偷偷编辑,查看效果。这个过程也是相当消耗精力啊。

PDF Expert 记录一下,这个工具还可以。

领导给了个链接,使用 TCPDF 这个库,有签名案例 ,这个 Demo 我也是佩服,一语中的,直击要害,差点我就以为我完成工作了。看到网站的第一感觉,这也太老土了;再看,确实老土,而且类库给维护也比较旧的。(核心功能稳定了嘛,毕竟 php 也这么多年了)。

等把类库下载下来,开始测试的时候,傻了。这个类库,集生成与签名于一体。对已有文件加签,是拒绝的。然后就开始搜吧,网上大多数 php 操作 pdf 都会有这个类库的影子。

好链接要留名 PHP利用FPDI 制作PDF 档案 (php并pdf, php签名pdf)

在它的文章中找到了关键线索,我也整理一个表格

prince 将html+css生成PDF文档的程序 https://www.princexml.com/
tcpdf php 生成 pdf文档的类库,支持加签名 https://tcpdf.org/docs/
FPDI 可以用来载入一个已存在的PDF 档案,给tcpdf使用 https://www.setasign.com/products/fpdi/about/

最后说一下FPDI的母公司,https://www.setasign.com/,有多款产品,其中 FPDI是免费的…

万事俱备,开始实践喽

实践过程

  1. 安装类库

    1
    2
    3
    compser require tecnickcom/tcpdf
    compser require setasign/fpdi
    compser require setasign/fpdi-tcpdf
  2. tcpdf example 001 主要是了为找找感觉,忘记代码从哪里弄过来的了

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    <?php
    require_once("vendor/autoload.php");


    // create new PDF document
    $pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);

    // set document information
    $pdf->SetCreator(PDF_CREATOR); //设置创建者
    $pdf->SetAuthor('Nicola Asuni'); //设置作者
    $pdf->SetTitle('TCPDF Example 001'); //设置文件的title
    $pdf->SetSubject('TCPDF Tutorial'); //设置主题
    $pdf->SetKeywords('TCPDF, PDF, example, test, guide'); //设置关键词
    // set default header data
    $pdf->SetHeaderData(PDF_HEADER_LOGO, PDF_HEADER_LOGO_WIDTH, PDF_HEADER_TITLE . ' 001', PDF_HEADER_STRING, array(0, 64, 255), array(0, 64, 128)); //设置头部,比如header_logo,header_title,header_string及其属性
    $pdf->setFooterData(array(0, 64, 0), array(0, 64, 128));

    // set header and footer fonts
    $pdf->setHeaderFont(Array(PDF_FONT_NAME_MAIN, '', PDF_FONT_SIZE_MAIN)); //设置页头字体
    $pdf->setFooterFont(Array(PDF_FONT_NAME_DATA, '', PDF_FONT_SIZE_DATA)); //设置页尾字体
    // set default monospaced font
    $pdf->SetDefaultMonospacedFont(PDF_FONT_MONOSPACED); //设置默认等宽字体
    // set margins
    $pdf->SetMargins(PDF_MARGIN_LEFT, PDF_MARGIN_TOP, PDF_MARGIN_RIGHT); //设置margins 参考css的margins
    $pdf->SetHeaderMargin(PDF_MARGIN_HEADER); //设置页头margins
    $pdf->SetFooterMargin(PDF_MARGIN_FOOTER); //设置页脚margins
    // set auto page breaks
    $pdf->SetAutoPageBreak(TRUE, PDF_MARGIN_BOTTOM); //设置自动分页
    // set image scale factor
    $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO); //设置调整图像自适应比例
    // set some language-dependent strings (optional) 设置一些与语言相关的字符串
    if (@file_exists(dirname(__FILE__) . '/lang/eng.php')) {
    require_once(dirname(__FILE__) . '/lang/eng.php');
    $pdf->setLanguageArray($l);
    }

    // ---------------------------------------------------------
    // set default font subsetting mode
    $pdf->setFontSubsetting(true); //设置默认字体子集模式
    // Set font
    // dejavusans is a UTF-8 Unicode font, if you only need to
    // print standard ASCII chars, you can use core fonts like
    // helvetica or times to reduce file size.
    $pdf->SetFont('dejavusans', '', 14, '', true); //设置字体
    // Add a page
    // This method has several options, check the source code documentation for more information.
    $pdf->AddPage(); //增加一个页面
    // set text shadow effect 设置文字阴影效果
    $pdf->setTextShadow(array('enabled' => true, 'depth_w' => 0.2, 'depth_h' => 0.2, 'color' => array(196, 196, 196), 'opacity' => 1, 'blend_mode' => 'Normal'));

    // Set some content to print
    $html = <<<EOD
    <h1>Welcome to Genecast <a href="http://www.tcpdf.org" style="text-decoration:none;background-color:#CC0000;color:black;">&nbsp;<span style="color:black;">TC</span><span style="color:white;">PDF</span>&nbsp;</a>!</h1>
    <i>This is the first example of TCPDF library.</i>
    <p>This text is printed using the <i>writeHTMLCell()</i> method but you can also use: <i>Multicell(), writeHTML(), Write(), Cell() and Text()</i>.</p>
    <p>Please check the source code documentation and other examples for further information.</p>
    <p style="color:#CC0000;">TO IMPROVE AND EXPAND TCPDF I NEED YOUR SUPPORT, PLEASE <a href="http://sourceforge.net/donate/index.php?group_id=128076">MAKE A DONATION!</a></p>
    EOD;

    // Print text using writeHTMLCell()
    $pdf->writeHTMLCell(0, 0, '', '', $html, 0, 1, 0, true, '', true); //使用writeHTMLCell打印文本
    // ---------------------------------------------------------
    // Close and output PDF document
    // This method has several options, check the source code documentation for more information.
    $v = $pdf->Output('/Users/apple/Desktop/example_001.pdf', 'F'); //I输出在浏览器上

    ?>
  3. 制作证书。仅用于自签,生产时需要向厂商购买。自己给自己签名,没有公信力啦

    1
    2
    3
    4
    5
    # 复制 tcpdf的 demo喽
    openssl req -x509 -nodes -days 365000 -newkey rsa:1024 -keyout tcpdf.crt -out tcpdf.crt
    openssl pkcs12 -export -in tcpdf.crt -out tcpdf.p12

    # 第二步的密码,在php代码里需要使用的哦
  1. 使用代码,给已存在的 pdf 文件签名

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    <?php
    require_once("vendor/autoload.php");

    use setasign\Fpdi\TcpdfFpdi;
    use setasign\Fpdi\PdfReader;

    $pdf = new TcpdfFpdi();
    $pdf->setPrintHeader(false);
    $pdf->setPrintFooter(false);

    // 不自动换行,很重要噢
    $pdf->SetAutoPageBreak(false);
    $pageCount = $pdf->setSourceFile('/Users/apple/Desktop/14.pdf');

    // 让旧的 pdf 所有页面载入到新的pdf中
    for($i = 1; $i <= $pageCount; $i++) {
    $pageId = $pdf->ImportPage($i);
    $s = $pdf->getTemplatesize($pageId);
    $pdf->AddPage($s['orientation'], $s);
    $pdf->useImportedPage($pageId);
    }


    $certificate = 'file:///Users/apple/webapps/pdf/tcpdf.crt';
    $info = array(
    'Name' => '臻和',
    'Location' => 'Office',
    'Reason' => '臻和出品',
    'ContactInfo' => 'http://www.genecast.com.cn',
    );
    // openssl 第二个命令输入的密码,这里需要噢
    $password = "123456";
    $pdf->setSignature($certificate, $certificate, $password, '', 2, $info);


    // 在第一页加签名
    $pdf->setPage(1);

    // *** set signature appearance ***
    // 签名展示的图片,可以用其它内容代替
    $pdf->Image('images.png', 130, 263, 38, 30, 'PNG');

    // 真实的签名块,不可见,盖在图片上边
    $pdf->setSignatureAppearance(130, 263, 38, 30);

    // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    // *** set an empty signature appearance ***
    //$pdf->addEmptySignatureAppearance(130, 260, 38, 16);

    $pdf->Output('/Users/apple/Desktop/example_002.pdf', 'F'); //I输出在浏览器上
  2. 查看效果喽, 博客刚建立 ,图片整的还不利索,这里不截图了。使用 Adobe Reader 可以看到完整的效果。特意用第三方编辑工具编辑再保存时,阅读器可以提示文档被修改过。

总结

这里其实不知道写啥,但感觉要有一个,小时候写作文留下的阴影吗。在编码阶段, composer 安装,并没有这么顺利,最开始没有找对类名。

另外,说一下FPDI的母公司,www.setasign.com ,商业公司网站的制作,果然是比 tcpdf 高出一个档次。

而且,他们家就有一个产品,PDF-Singer,对 PDF签名。

如果最后没有找到现在的方案,我肯定会向领导申请购买的。文档,Demo,一应俱全,虽然全是英文。唯一不爽的就是没有购买授权,我没有找着测试类库在哪里。

好吧,技术探索完成,后续在生产中再有心得,再补上。我是王大力,谢谢您的观看~~

后记1

到了生产环节,发现一个很严重的Bug。

提供PDF签名证书的厂商,产品是通地 UsbKey 发放给客户(也就是我们),在 windows 下,通过 adobe 的工具,对 pdf 文件进行签名。

逆天了,法律规定他们不能提供文件形式的软证书。

或者使用他们提供的在线API,大体意思就是把文件传给厂商,他们帮你签你的名字。(这样安全吗?)

厂商的客服跟我说:这个就像纸质文件,需要盖公章一样,公章也是需要专人保管,一份一份地盖。

我默默的认同了。

后记2

  1. 找到一家可以提供软证书的公司,http://www.cfca.com.cn/ , 需要将 pfx 证书转换格式,命令请参考:

    1
    openssl pkcs12 -in cfca.pfx -out tcpdf.crt -nodes
  2. setasign 的 PDF-Singer 申请了测试license,下载了代码,由于需要使用 ioncube 而恶心到到了我(主要是自动安装失败),放弃。

  3. 已签名的文档,在 adobe 的阅读器里,显示 “文档证书的有效性未知。无法验证作者。” 那么你需要如下操作: 首选项–>信任管理器->自动AATL更新–>立即更新。 更新需要一段时间,更新后重新验证即可正常显示签名信息。

  4. 各种生成,各种猜测,各种阅读器查看效果,心好累,比写代码都累。(最近比较困~~~)

  5. 正式上线后,又出现一个问题。一台win7的电脑,无法验证证书。厂家给出的解释是win7部分系统不能自动寻找证书链路,所以无法验证。厂家给出的解决方案是把中级证书和用户证书合并成新的证书,签名时把中级证书签进去。 但合并证书程序会报错。

  6. 最终采用了 Java 外挂程序的方案 java -jar JSignPdf/JSignPdf.jar ,使用 pfx 和密码 直接隐藏式签名。测试大部分机器都能正常。此事告一段落

后记3

文件签名的效果得到领导的认可,于是产生了要把旧的文件都转换成签名形式。之前我怎么没有想到这个延展需求呢。

干呗。先找一款去PDF密码的工具(当然,我是知道密码的)。 PDFBox 的命令行工具非常好用。然后说一下工作流:

  1. 从数据库中导出相关的数据。每条数据代表一个文件。
  2. 从阿里云下载这个文件
  3. 对文件实施移除密码、增加签名操作
  4. 在阿里上备份这个文件。
  5. 将本的文件覆盖阿里旧的文件。

一次性的工具类,使用GO语言去编写,本以为很简单的,结果还是搞了3天才转换完成。使用带缓存的 chan 去做pipeline 控制。耗时点:

  1. log 选择。选择综合证啊。 zero/log ,这事以后不做了
  2. exec comand。主要是在判断正常远行,还是异常费了点功夫。不得不说GO语言在这块的处理还是挺好的。关键字: cmd.ProcessState
  3. 串流测试,本地下载文件较慢,debug一次需要等待
  4. 增加中断、继续执行的能力。因为整体跑下了,跑了三个多小时。