⏱️

電子帳簿保存法対応 - PDF文書にタイムスタンプを付与するには(RFC-3161/PAdES)

に公開

この記事について

この記事は技術書典17の新刊として発表した「自分でつくる!電帳法対応タイムスタンプツール」より内容を抜粋したものです。2024年当時はWeb上に電子帳簿保存法・タイムスタンプ付与(PAdES)に関する情報がほとんどなく苦労した経験から、できるだけタイムスタンプ関連の知見を広く公開すべきと思い執筆しました。

https://techbookfest.org/product/hRX58ECcTNDh3jZUTN16yA

電子帳簿保存法の概要

電子帳簿保存法(通称:電帳法)においては、各種証明書類にタイムスタンプを付与することを求めています。ただしすべての書類について必要であるということではありません。会社規模によってもその扱いは異なります。会計・税務申告に使うような電子データや文書については基本的にタイムスタンプ付与が必要であるものと考えるのがよいでしょう。

タイムスタンプ管理の必要要件

タイムスタンプは電子ファイルにおける次の要件を満たすために使われます。

  • 存在証明... ファイルが指定の日時に存在したこと
  • 完全性証明... タイムスタンプが付与されて以降データが改ざんされていないこと

ファイルに対して正しくタイムスタンプが付与されていれば、これらの要件は満たせていることになります。そして同時に対応が必要となるのが、タイムスタンプが付与されたファイルに対する検索要件という問題です。

一定以上の年間売り上げのある会社において、電子ファイルを税法上有効なものとして扱うには単にタイムスタンプ付与済みのファイルを保管するだけでは足りません。税務当局の求めに応じて特定の課税期間に該当するファイルを速やかに検索でき、かつそれらに付与されたタイムスタンプを一括で検証ができる状態にしておく必要があります。

タイムスタンプの仕組み

電子帳簿保存法におけるタイムスタンプとはRFC-3161 の規格に沿って発行される電子署名の一種を指します。このタイムスタンプはファイルのハッシュ値をもとに付与されるため、途中でファイル内容への改ざんが加えられた場合にはそれをすぐ検出できます。

タイムスタンプのトークンを得るには、まずファイルのハッシュ値を計算してそれを付加したHTTPリクエストをタイムスタンプ局(TSA)に対して送信します。タイムスタンプ局はそれに時刻情報・証明書等のデータを付加したトークンを発信元へと送り返すことになります。このトークンを保存する、もしくは後述する通りPDFファイル内に直接埋め込む等の対応が必要となります。

Adobe Acrobat Reader 上での署名の確認

タイムスタンプ情報はページ上部に表示されます。もしもタイムスタンプ付与済みの文書が改ざんされるなど、不正な変更が加わったような場合は「無効な署名があります」という表示となります。


「PDF 文書を対象にした電子署名/タイムスタンプ技術の実装例」
https://www.soumu.go.jp/main_content/000600453.pdf より


freee ヘルプセンター「文書のタイムスタンプを確認する」
https://support.freee.co.jp/hc/ja/articles/6368109978009 より

タイムスタンプ局情報の準備

タイムスタンプ局を使うにあたっては次の情報が必要となります。

  • タイムスタンプ局の接続先URL
  • BASIC認証用アカウントID
  • BASIC認証用パスワード

電帳法対応にあたっては、時刻認証事業者4社のいずれかのタイムスタンプサービスを使う必要があります。タイムスタンプサービスの契約が完了するとタイムスタンプ局接続用のURLやBASIC認証用のアカウント情報を受領することになります。

時刻認証事業者一覧

https://www.soumu.go.jp/main_sosiki/joho_tsusin/top/ninshou-law/timestamp.html

総務省 - タイムスタンプについて
https://www.soumu.go.jp/main_sosiki/joho_tsusin/top/ninshou-law/timestamp.html

その際には社内プロキシ等のセキュリティによる通信の失敗が発生しないように注意しましょう。単に
タイムスタンプ局への接続・動作を試してみたい場合には、BASIC認証の手間のない無
料のタイムスタンプサービスを利用できます。

Stage 1: タイムスタンプの取得

環境構築・ライブラリ

  • 利用言語: Java17
import org.apache.commons.codec.digest.DigestUtils;

// 各種PDF編集、ファイル・タイムスタンプ埋め込み処理用
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.encryption.SecurityProvider;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.COSFilterInputS→
tream;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.ExternalSigningSupport;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;

// 署名データ・X.509証明書処理用
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.SignerInformationVerifier;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
import org.bouncycastle.tsp.*;

ファイルのハッシュダイジェスト値の算出

タイムスタンプ局へのリクエスト時にまずはダイジェスト値を送信する必要があります。ダイジェスト値の計算はタイムスタンプ取得の第一歩と言えるでしょう。Java においてファイルのハッシュダイジェスト値を求めるには、DigestUtils もしくは Message Digest を使う方法が存在します。ここでは DigestUtils を使った場合のサンプルを掲載します。

// 入力ファイルストリームの取得
String inputFilePath = "/sample/input_file.txt"
File inputFile = new File(inputFilePath);
FileInputStream fis = new FileInputStream(inputFile);

// ファイルのハッシュダイジェスト値の算出(SHA-256)
byte[] sha256Bytes = DigestUtils.sha256(fis);

タイムスタンプ局へのHTTPSリクエスト

基本的な部分は通常のHTTPS リクエストと同じですが、ヘッダ情報に以下を含める必要があります。

  • Content-Type
    • application/timestamp-query を指定する
  • Authorization
    • Basic [TSAアカウント名]:[TSAパスワード] の形式のBASE64エンコード文字列を指定する
// タイムスタンプ局用のユーザー名・パスワードの宣言
// NOTE:
//   各自接続先の情報を記入する。
//   BASIC認証なしのTSA利用時はアカウント・パスワードは空文字とする
String tsaServiceURL = "https://your_tsa_service_url";
String tsaAccount = "Your TSA Account";
String tsaPassword = "Your TSA Password";

// 入力ファイルのパス指定
// NOTE: タイムスタンプを付与したい任意のファイルを指定すること
String inputFilePath = "/sample/input_file.txt";

// 入力ファイルのハッシュダイジェスト値の算出(SHA-256)
// NOTE: sha256によるハッシュ化を忘れると正しい応答が返ってこないので注意
FileInputStream fis = new FileInputStream(new File(inputFilePath));
byte[] sha256Bytes = DigestUtils.sha256(fis);

// タイムスタンプリクエストの作成
// NOTE: setCertReq指定で証明書データ付加を要求可能
TimeStampRequestGenerator reqGen = new TimeStampRequestGenerator();
reqGen.setCertReq(true);
TimeStampRequest timeStampReq = reqGen.generate(TSPAlgorithms.SHA256, sha256Bytes);

// HTTPクライアントの宣言
HttpClient client = HttpClient.newBuilder().
			version(Version.HTTP_1_1).
			build();

// タイムスタンプ局のBASIC認証情報の設定
String credential = Base64.getEncoder().
			encodeToString((tsaAccount + ":" + tsaPassword).
			getBytes(StandardCharsets.UTF_8));

// HTTPリクエスト情報の作成
HttpRequest req = HttpRequest.newBuilder()
			.uri(URI.create(tsaServiceURL))
			.header("Content-Type","application/timestamp-query")
			.header("Authorization", "Basic " + credential)
			.POST(HttpRequest.BodyPublishers.ofByteArray(timeStampReq.getEncoded()))
			.build();

// HTTPリクエストの送信
// NOTE: タイムスタンプ発行制限(発行頻度・上限数)にかかる場合はエラー発生
var res = client.send(req, HttpResponse.BodyHandlers.ofByteArray());
if (res.statusCode() != 200) {
    System.out.println("タイムスタンプ発行のリクエストに失敗しました");
    System.out.println("Status Code: " + res.statusCode());
    System.exit(-1);
}

// レスポンスからタイムスタンプトークンを取得
byte[] resArray = res.body();

TimeStampResponse resp = new TimeStampResponse(resArray);
TimeStampToken token = resp.getTimeStampToken();

タイムスタンプ局およびX.509証明書情報の確認

タイムスタンプ局の情報を得るにはタイムスタンプトークンを表す BouncyCastleTimeStampResponseTimeStampToken 型の変数だけで完結します。また、X.509証明書情報を確認するには CMSSignedData, SiginerInformation, X509CertificateHolder 等の型をもった変数を取得するなど込み入った手順が必要になります。

//
// 変数resにHTTPレスポンス情報が入っているものとする
// 詳細は前のサンプルコードを参照
//
byte[] resArray = res.body();

// レスポンスからタイムスタンプトークンを取得
TimeStampResponse resp = new TimeStampResponse(resArray);
TimeStampToken token = resp.getTimeStampToken();

// トークンからCMS形式情報の取得
CMSSignedData signedData = token.toCMSSignedData();

// ルート証明書情報の取得
// NOTE: 個別の証明書情報を取得するときはイテレータを経由する必要がある
SignerInformation signerInfo =
    signedData.getSignerInfos().iterator().next();
Collection<X509CertificateHolder> matches =
    signedData.getCertificates().getMatches(signerInfo.getSID());
X509CertificateHolder cert = matches.iterator().next();

// タイムスタンプの有効期限を取得
// NOTE: TimeStampToken内には有効期限の情報は含まれないため、証明書から取得
Date tsExpireDate = cert.getNotAfter();

// タイムスタンプ情報の出力
System.out.println("- シリアル番号 : " + tsInfo.getSerialNumber());
System.out.println("- 生成時刻 : " + tsInfo.getGenTime());
System.out.println("- タイムスタンプ局 : " + tsInfo.getTsa().getName());
System.out.println("- 有効期限 : " + tsExpireDate);
System.out.println("- 証明書発行者 : " + cert.getIssuer());

FreeTSAでの出力サンプル

- シリアル番号: 46424973
- 生成時刻: Sat Jul 20 06:37:40 JST 2024
- タイムスタンプ局: O=Free TSA,OU=TSA,DESCRIPTION=This certificate digitally s→
igns documents and time stamp requests made using the freetsa.org online serv→
ices,CN=www.freetsa.org,E=busilezas@gmail.com,L=Wuerzburg,C=DE,ST=Bayern
- 有効期限: Wed Mar 11 10:57:39 JST 2026
- 証明書発行者: O=Free TSA,OU=Root CA,CN=www.freetsa.org,E=busilezas@gmail.co→
m,L=Wuerzburg,ST=Bayern,C=DE

Stage 2: タイムスタンプの埋め込みと検証

PDFへのタイムスタンプの埋め込み

Stage 1においてタイムスタンプ局からのトークンを取得しましたが、これをPDFファイル内へと埋め込みます。トークンを埋め込むことで、管理がより容易になります。タイムスタンプ自体は単なる電子署名であるため、必ずしも印影は必要ありません。しかし一目見てタイムスタンプが付加されていることを確認しやすくするため、あえてファイル内に印影を追加することもあります。

// タイムスタンプ局関連の情報
String tsaServiceURL = "https://your_tsa_service_url";
String tsaUser = "Your TSA User Name";
String tsaPassword = "Your TSA Password";

// PDDocumentの初期化
PDDocument document = Loader.loadPDF(new File(inputFilePath));

// 出力ストリームの設定
FileOutputStream fos = new FileOutputStream(outputFilePath);

// タイムスタンプ(署名)情報の設定
PDSignature signature = new PDSignature();
signature.setType(COSName.DOC_TIME_STAMP);
signature.setSubFilter(COSName.getPDFName("ETSI.RFC3161"));

// PDFファイルへの署名情報の追加
document.addSignature(signature);

// 外部署名情報の追加
// NOTE:
//   署名はタイムスタンプ局のものを利用するため、外部署名としての扱いになる
//   このような場合はPDFBoxのExternalSigningSupportの機能を使う
ExternalSigningSupport ess =
    document.saveIncrementalForExternalSigning(fos);
InputStream docInputStream = ess.getContent();

// タイムスタンプ局からのタイムスタンプトークン取得
// NOTE:
//   ここではタイムスタンプ局へのHTTPSリクエスト送信の上、
//   タイムスタンプトークンを返すメソッドがすでに実装されているものとする
//   実際のリクエスト送信処理については前述の通り
TimeStampToken token = this.getTimestampToken(
        tsaServiceURL, tsaUser, tsaPassword, docInputStream);

// CMS署名データの取得
CMSSignedData signedData = token.toCMSSignedData();

// タイムスタンプ(署名)の設定
ess.setSignature(signedData.getEncoded());

参考: PDFBox examples/singature - CreateSignature.java
https://github.com/apache/pdfbox/blob/trunk/examples/src/main/java/org/apache/pdfbox/examples/signature/CreateSignature.java

タイムスタンプ内のダイジェスト値検証

PDFにはByteRangeの情報が含まれているため、電子署名のデータ部分を除いたオリジナルのデータを抜き出すことが可能です。この情報を使うことで、タイムスタンプトークンを付与する前のファイルのハッシュダイジェスト値の計算もできます。

「PDF文書を対象にした電子署名/タイムスタンプ技術の実装例」
https://www.soumu.go.jp/main_content/000600453.pdf

PDFファイルのダイジェスト値検証においては、2つの手順で得たダイジェスト値を比較することになります。

  • 手順1 ... タイムスタンプに埋め込まれたダイジェスト値
  • 手順2 ... ByteRangeのデータ範囲のデータから計算したダイジェスト値

手順1, 2で求めたダイジェスト値が一致していれば、文書は改ざんされておらずタイムスタンプは有効な状態であると判断できます。

// ファイルパスの定義
String inputFilePath = "/sample/input_signed_file.pdf";

// タイムスタンプ付与済みPDFを開く
PDDocument signedDoc = Loader.loadPDF(Files.readAllBytes(inputFilePath));

// 署名のうち最新のものを取得
PDSignature signature = signedDoc.getLastSignatureDictionary();

// 署名データの取得
byte[] contents = signature.getContents();
CMSSignedData signedData = new CMSSignedData(contents);

// タイムスタンプトークン情報の取得
TimeStampToken token = new TimeStampToken(signedData);
TimeStampTokenInfo tsInfo = token.getTimeStampInfo();

// ByteRange情報を取得する
// NOTE:
//   ByteRangeには署名対象のバイト範囲が記述されている
//   これを参照することでタイムスタンプ付与以前のデータ範囲がわかる
FileInputStream fis = new FileInputStream(new File(inputFilePath));
InputStream signedContentAsStream =
    new COSFilterInputStream(fis, signature.getByteRange());

// Step 1: PDFに埋め込まれたタイムスタンプからダイジェスト値を取得
String hashAlgorithm = tsInfo.getMessageImprintAlgOID().getId();
MessageDigest md = MessageDigest.getInstance(hashAlgorithm);

// Step 2: ByteRange範囲のファイルデータからダイジェスト値を計算する
try (DigestInputStream dis =
    new DigestInputStream(signedContentAsStream, md)) {
    while (dis.read() != -1) {
	// 何もしない
    }
}

// Step 1, 2で取得したダイジェスト値を比較する
// NOTE:
//   Arrays.equals によってダイジェスト値を比較する
//   通常のString型やBASE64文字列で比較を行おうとすると比較結果が一致しないので注意
if (Arrays.equals(md.digest(), tsInfo.getMessageImprintDigest())) {
    System.out.println("検証成功: ファイル内容は変更されていません");
} else {
    System.out.println("検証失敗: ファイル内容が変更された可能性があります");
}

参考: PDFBox examples/singature
https://github.com/apache/pdfbox/blob/trunk/examples/src/main/java/org/apache/pdfbox/examples/signature/ShowSignature.java

タイムスタンプおよび電子証明書の検証

ここではタイムスタンプのハッシュダイジェスト値比較(改ざん検出)とは別に、タイムスタンプと電子証明書(ルート証明書除く)の検証を行います。PDFBoxのサンプルコード内で定義されている検証処理を使ってコードの短縮化を図っています。

// ファイルパスの定義
String inputFilePath = "/sample/input_signed_file.pdf";

// タイムスタンプ付与済みPDFを開く
PDDocument signedDoc = Loader.loadPDF(Files.readAllBytes(inputFilePath));

// 署名のうち最新のものを取得
PDSignature signature = signedDoc.getLastSignatureDictionary();

// 署名データの取得
byte[] contents = signature.getContents();
CMSSignedData signedData = new CMSSignedData(contents);

// タイムスタンプトークン情報の取得
TimeStampToken token = new TimeStampToken(signedData);

// X.509電子証明書の取得と検証
Collection<X509CertificateHolder> tstMatches =
    token.getCertificates().getMatches(timeStampToken.getSID());
X509CertificateHolder certificateHolder =
    tstMatches.iterator().next();
SignerInformationVerifier siv =
    new JcaSimpleSignerInfoVerifierBuilder().
    setProvider(SecurityProvider.getProvider()).build(certificateHolder);

token.validate(siv);

// 証明書チェーンの検証(ルート証明書除く)
Collection<X509CertificateHolder> certificateHolders =
    certificatesStore.getMatches(null);
Set<X509Certificate> additionalCerts = new HashSet<>();
JcaX509CertificateConverter certificateConverter =
    new JcaX509CertificateConverter();

for (X509CertificateHolder certHolder : certificateHolders) {
    X509Certificate certificate =
        certificateConverter.getCertificate(certHolder);
    if (!certificate.equals(certFromSignedData)) {
         additionalCerts.add(certificate);
    }
}

certificateVerifier.verifyCertificate(
    certFromSignedData, additionalCerts, true, signDate);

Discussion