Apache PDFBoxを使用してPDFに電子署名（MDP署名）

急ぎで書いたので情報不足です。
動くものはできたので、追々完成度高めたい気持ちはあります。

## Asposeが使い物にならないのでPDFBoxでMDP署名
前回の記事。
署名理由等に日本語入れるとファイルサイズが肥大化するため、PDFBoxでやっていく。
(https://qiita.com/katsuo0004/items/2b17d3083107c3b6f846)

## 準備：Windows11 WSLでUbuntu 22.04.3 で証明書（PFX形式）作成していく
自己署名証明書（テスト用・非公開用途）
自分で作成できる（無料）
法的効力はない（自己証明なので第三者証明ではない）
開発・社内運用用などに便利
🔧 例：OpenSSLを使って自己署名証明書を作る


秘密鍵（Private Key）を作成
```
openssl genrsa -out mykey.key 2048
```
証明書署名要求（CSR）を作成
途中でいろいろ聞かれるので適当に入力してOK（Common Name だけ分かりやすくするとよい）
```
openssl req -new -key mykey.key -out myreq.csr
```
自己署名証明書（CRT）を作成
```
openssl x509 -req -days 365 -in myreq.csr -signkey mykey.key -out mycert.crt
```
PFXファイル（署名に使う形式）を作成
エクスポート時にパスワードを求められます（署名時に必要になるので覚えておく）
```
openssl pkcs12 -export -out mycert.pfx -inkey mykey.key -in mycert.crt
```
仮想環境（たとえば WSL, Docker, 仮想マシン, リモートシェル**など）で操作してる場合、仮想マシン内の C:\Users\Pasokonsan\ は、ホスト側（デスクトップのWindows）から見えないので、見えるところにコピー
```
cp mycert.pfx /mnt/c/Users/Pasokonsan/OneDrive/Desktop/
cp mycert.crt /mnt/c/Users/Pasokonsan/OneDrive/Desktop/
cp mykey.key /mnt/c/Users/Pasokonsan/OneDrive/Desktop/
cp myreq.csr /mnt/c/Users/Pasokonsan/OneDrive/Desktop/
```

🖋 PDFに署名する手順（Adobe Acrobat Proの場合）
mycert.pfx をPCにダブルクリックしてインストール（Windowsなら証明書ストアに）

Adobe Acrobat を開く

[ツール] → [証明書] → [デジタル署名]

署名位置を指定すると、自分の証明書を選べるようになります

🔍 注意点
自己署名なので、他の人に送った場合は**「信頼されていない署名」**と表示されます。
ローカルや社内用途、テストで使うには十分便利です。
mycert.pfx は大事な鍵なので絶対に他人に渡さないでください。

## APIを作成していく
以下でプロジェクト作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4048003/541be72e-56c7-484d-a9fc-77416e6b3db4.png)
(https://start.spring.io/)

## pdfboxとbouncycastle追加
```pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.4.4</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.example</groupId>
	<artifactId>demo</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>demo</name>
	<description>Demo project for Spring Boot</description>
	<url/>
	<licenses>
		<license/>
	</licenses>
	<developers>
		<developer/>
	</developers>
	<scm>
		<connection/>
		<developerConnection/>
		<tag/>
		<url/>
	</scm>
	<properties>
		<java.version>17</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.apache.pdfbox</groupId>
			<artifactId>pdfbox</artifactId>
			<version>3.0.1</version>
		</dependency>
		<!-- BouncyCastle Provider -->
		<dependency>
			<groupId>org.bouncycastle</groupId>
			<artifactId>bcprov-jdk15to18</artifactId>
			<version>1.78</version>
		</dependency>

		<!-- BouncyCastle PKIX -->
		<dependency>
			<groupId>org.bouncycastle</groupId>
			<artifactId>bcpkix-jdk15to18</artifactId>
			<version>1.78</version>
		</dependency>

	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-compiler-plugin</artifactId>
				<configuration>
					<annotationProcessorPaths>
						<path>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</path>
					</annotationProcessorPaths>
				</configuration>
			</plugin>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>
	</build>

</project>
```

## リクエストでPDFファイルを受け取って決まった場所に出力するAPIを作成
```PdfController.java
package com.example.demo.controller;

import com.example.demo.service.PdfSignatureService;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import java.io.File;

@RestController
@RequestMapping("/demo")
public class PdfController {

    @Autowired
    private PdfSignatureService pdfSignatureService;

    @RequestMapping(value = {"/pdfSignature"}, method = RequestMethod.POST)
    public void create(@RequestBody byte[] request) throws Exception {
        pdfSignatureService.execute(request, new File("C:\\Users\\Pasokonsan\\Downloads\\output.pdf"));
    }
}
```
```PdfSignatureService.java
package com.example.demo.service;

import org.springframework.stereotype.Service;

import java.io.*;
import java.security.*;
import java.security.cert.Certificate;

@Service
public class PdfSignatureService {

    public void execute(byte[] request, File outputFile) throws Exception {
        KeyStore keystore = KeyStore.getInstance("PKCS12");
        try (InputStream keystoreStream = new FileInputStream("C:\\Users\\Pasokonsan\\OneDrive\\Desktop\\mycert.pfx")) {
            keystore.load(keystoreStream, "password".toCharArray());
        }
        String alias = keystore.aliases().nextElement();
        PrivateKey privateKey = (PrivateKey) keystore.getKey(alias, "password".toCharArray());
        Certificate[] certChain = keystore.getCertificateChain ( alias);

        PdfSign pdfSign = new PdfSign ( privateKey, certChain );
        pdfSign.signPDF ( request, outputFile );
    }
}
```

```PdfSign.java
package com.example.demo.service;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.CMSSignedDataGenerator;
import org.bouncycastle.cms.CMSTypedData;
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.bouncycastle.util.Store;

import java.io.*;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Calendar;
import java.util.List;

public class PdfSign  implements SignatureInterface {
    private final PrivateKey privateKey;
    private final Certificate[] certificateChain;

    public PdfSign(PrivateKey privateKey, Certificate[] certificateChain) {
        this.privateKey = privateKey;
        this.certificateChain = certificateChain;
    }

    public void signPDF(byte[] inputFile, File outputFile) throws Exception {
        try (PDDocument document = Loader.loadPDF(inputFile)) {
            PDSignature signature = new PDSignature();
            signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
            signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
            signature.setName("私の名前");
            signature.setLocation("私の場所");
            signature.setReason("MDP署名したい");
            signature.setSignDate( Calendar.getInstance());

            // MDP設定を追加
            addMDPPermission(document, signature, 0); // 変更不許可

            document.addSignature(signature, this);

            try (FileOutputStream fos = new FileOutputStream(outputFile)) {
                document.saveIncremental(fos);
            }
        }
    }

    @Override
    public byte[] sign(InputStream content) throws IOException {
        try {
            List<Certificate> certList = Arrays.asList(certificateChain);
            Store<?> certStore = new JcaCertStore (certList);

            CMSTypedData msg = new CMSProcessableInputStream(content);

            // BouncyCastleをプログラム内で明示的にプロバイダとして登録
            Security.addProvider(new BouncyCastleProvider ());
            ContentSigner signer = new JcaContentSignerBuilder ("SHA256withRSA")
                    .setProvider("BC")
                    .build(privateKey);

            CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
            gen.addSignerInfoGenerator(
                    new JcaSignerInfoGeneratorBuilder (
                            new JcaDigestCalculatorProviderBuilder ().build())
                            .build(signer, (X509Certificate) certificateChain[0])
            );

            gen.addCertificates(certStore);

            CMSSignedData signedData = gen.generate(msg, false);

            return signedData.getEncoded();
        } catch (Exception e) {
            throw new IOException("署名中にエラーが発生しました", e);
        }
    }

    private void addMDPPermission(PDDocument doc, PDSignature signature, int accessPermissions) {
        COSDictionary transformParams = new COSDictionary();
        transformParams.setItem( COSName.TYPE, COSName.getPDFName("TransformParams"));
        transformParams.setItem(COSName.V, COSName.getPDFName("1.2"));
        transformParams.setInt(COSName.P, accessPermissions); // 1 = no changes
        transformParams.setDate(COSName.getPDFName("DT"), Calendar.getInstance());

        COSDictionary sigRef = new COSDictionary();
        sigRef.setItem(COSName.TYPE, COSName.getPDFName("SigRef"));
        sigRef.setItem(COSName.TRANSFORM_METHOD, COSName.getPDFName("DocMDP"));
        sigRef.setItem(COSName.TRANSFORM_PARAMS, transformParams);
        sigRef.setItem(COSName.getPDFName("Data"), doc.getDocumentCatalog().getCOSObject());

        COSArray referenceArray = new COSArray();
        referenceArray.add(sigRef);
        signature.getCOSObject().setItem(COSName.REFERENCE, referenceArray);

        COSDictionary perms = new COSDictionary();
        perms.setItem(COSName.DOCMDP, signature.getCOSObject());
        doc.getDocumentCatalog().getCOSObject().setItem(COSName.PERMS, perms);
    }

    // サブクラス: CMS用にInputStreamを扱うためのヘルパー
    private static class CMSProcessableInputStream implements CMSTypedData {
        private final InputStream input;

        CMSProcessableInputStream(InputStream is) {
            this.input = is;
        }

        @Override
        public Object getContent() {
            return input;
        }

        @Override
        public void write(OutputStream out) throws IOException, CMSException {
            byte[] buffer = new byte[8 * 1024];
            int read;
            while ((read = input.read(buffer)) >= 0) {
                out.write(buffer, 0, read);
            }
            input.close();
        }

        @Override
        public ASN1ObjectIdentifier getContentType() {
            return PKCSObjectIdentifiers.data;
        }
    }
}
```

詰まった箇所
・document.saveIncremental(fos);で必ず保存すること。document.save(fos);ではだめでした。

## PostmanでAPIコール
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4048003/33204a22-15f2-48ee-9031-dc8619ce1515.png)
・http://localhost:8080/demo/pdfSignature
・POSTでボディにバイナリでファイル選択
・javaで受け取るときはコントローラーで @RequestBody byte[] で受け取ること

## 出力結果こちら
このMDP署名が低容量でやりたかった(´;ω;｀)
43kb→63kb程度の増加ですので、たぶん大丈夫だと思う。
コード中身は全然見れてないので品質に問題あるとは思うので、あくまで参考まで。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4048003/f4a65fba-5afd-44e7-859f-f66f69b48b91.png)